From 74c0a85e3f52875c234d4bcbdf3f160d16d20d28 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Thu, 19 Mar 2026 09:07:19 +0530 Subject: [PATCH 01/10] refactor(graph): modularize event loop helpers Extract EventLoopNode helper logic into focused event_loop modules while keeping the node responsible for orchestration. Preserve the existing behavior and compatibility for compaction, event publishing, cursor persistence, synthetic tools, judge evaluation, stall detection, tool result handling, and subagent escalation wiring. --- core/framework/graph/event_loop/__init__.py | 6 + core/framework/graph/event_loop/compaction.py | 480 ++++ .../graph/event_loop/cursor_persistence.py | 219 ++ .../graph/event_loop/event_publishing.py | 332 +++ .../graph/event_loop/judge_pipeline.py | 144 ++ .../graph/event_loop/stall_detector.py | 106 + .../graph/event_loop/subagent_executor.py | 397 +++ .../graph/event_loop/synthetic_tools.py | 371 +++ .../graph/event_loop/tool_result_handler.py | 515 ++++ core/framework/graph/event_loop_node.py | 2169 +++-------------- 10 files changed, 2865 insertions(+), 1874 deletions(-) create mode 100644 core/framework/graph/event_loop/__init__.py create mode 100644 core/framework/graph/event_loop/compaction.py create mode 100644 core/framework/graph/event_loop/cursor_persistence.py create mode 100644 core/framework/graph/event_loop/event_publishing.py create mode 100644 core/framework/graph/event_loop/judge_pipeline.py create mode 100644 core/framework/graph/event_loop/stall_detector.py create mode 100644 core/framework/graph/event_loop/subagent_executor.py create mode 100644 core/framework/graph/event_loop/synthetic_tools.py create mode 100644 core/framework/graph/event_loop/tool_result_handler.py diff --git a/core/framework/graph/event_loop/__init__.py b/core/framework/graph/event_loop/__init__.py new file mode 100644 index 0000000000..1ec8a803e9 --- /dev/null +++ b/core/framework/graph/event_loop/__init__.py @@ -0,0 +1,6 @@ +"""EventLoopNode subpackage — modular components of the event loop orchestrator. + +All public symbols are re-exported by the parent ``event_loop_node.py`` for +backward compatibility. Internal consumers may import directly from these +submodules for clarity. +""" diff --git a/core/framework/graph/event_loop/compaction.py b/core/framework/graph/event_loop/compaction.py new file mode 100644 index 0000000000..61b432c218 --- /dev/null +++ b/core/framework/graph/event_loop/compaction.py @@ -0,0 +1,480 @@ +"""Conversation compaction pipeline. + +Implements the multi-level compaction strategy: +1. Prune old tool results +2. Structure-preserving compaction (spillover) +3. LLM summary compaction (with recursive splitting) +4. Emergency deterministic summary (no LLM) +""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +from framework.graph.conversation import NodeConversation +from framework.graph.node import NodeContext + +logger = logging.getLogger(__name__) + +# Limits for LLM compaction +LLM_COMPACT_CHAR_LIMIT: int = 240_000 +LLM_COMPACT_MAX_DEPTH: int = 10 + + +async def compact( + ctx: NodeContext, + conversation: NodeConversation, + accumulator: Any | None, # OutputAccumulator + *, + config: Any, # LoopConfig + event_bus: Any | None, # EventBus + char_limit: int = LLM_COMPACT_CHAR_LIMIT, + max_depth: int = LLM_COMPACT_MAX_DEPTH, +) -> None: + """Run the full compaction pipeline if conversation needs compaction. + + Pipeline stages (in order, short-circuits when budget is restored): + 1. Prune old tool results + 2. Structure-preserving compaction (free, no LLM) + 3. LLM summary compaction (recursive split if too large) + 4. Emergency deterministic summary (fallback) + """ + if not conversation.needs_compaction(): + return + + ratio_before = conversation.usage_ratio() + phase_grad = getattr(ctx, "phase_graduated", False) + + # --- Step 1: Prune old tool results (free, fast) --- + conversation.prune_old_tool_results(keep_recent=4) + if not conversation.needs_compaction(): + await log_compaction(ctx, conversation, ratio_before, event_bus) + return + + # --- Step 2: Standard structure-preserving compaction (free, no LLM) --- + spill_dir = config.spillover_dir + if spill_dir: + await conversation.compact_preserving_structure( + spillover_dir=spill_dir, + keep_recent=4, + phase_graduated=phase_grad, + ) + if not conversation.needs_compaction(): + await log_compaction(ctx, conversation, ratio_before, event_bus) + return + + # --- Step 3: LLM summary compaction --- + if ctx.llm is not None: + logger.info( + "LLM summary compaction triggered (%.0f%% usage)", + conversation.usage_ratio() * 100, + ) + try: + summary = await llm_compact( + ctx, + list(conversation.messages), + accumulator, + char_limit=char_limit, + max_depth=max_depth, + max_context_tokens=config.max_context_tokens, + ) + await conversation.compact( + summary, + keep_recent=2, + phase_graduated=phase_grad, + ) + except Exception as e: + logger.warning("LLM compaction failed: %s", e) + + if not conversation.needs_compaction(): + await log_compaction(ctx, conversation, ratio_before, event_bus) + return + + # --- Step 4: Emergency deterministic summary (LLM failed/unavailable) --- + logger.warning( + "Emergency compaction (%.0f%% usage)", + conversation.usage_ratio() * 100, + ) + summary = build_emergency_summary(ctx, accumulator, conversation, config) + await conversation.compact( + summary, + keep_recent=1, + phase_graduated=phase_grad, + ) + await log_compaction(ctx, conversation, ratio_before, event_bus) + + +# --- LLM compaction with binary-search splitting ---------------------- + + +async def llm_compact( + ctx: NodeContext, + messages: list, + accumulator: Any | None = None, + _depth: int = 0, + *, + char_limit: int = LLM_COMPACT_CHAR_LIMIT, + max_depth: int = LLM_COMPACT_MAX_DEPTH, + max_context_tokens: int = 128_000, +) -> str: + """Summarise *messages* with LLM, splitting recursively if too large. + + If the formatted text exceeds ``LLM_COMPACT_CHAR_LIMIT`` or the LLM + rejects the call with a context-length error, the messages are split + in half and each half is summarised independently. Tool history is + appended once at the top-level call (``_depth == 0``). + """ + from framework.graph.conversation import extract_tool_call_history + from framework.graph.event_loop.tool_result_handler import is_context_too_large_error + + if _depth > max_depth: + raise RuntimeError(f"LLM compaction recursion limit ({max_depth})") + + formatted = format_messages_for_summary(messages) + + # Proactive split: avoid wasting an API call on oversized input + if len(formatted) > char_limit and len(messages) > 1: + summary = await _llm_compact_split( + ctx, + messages, + accumulator, + _depth, + char_limit=char_limit, + max_depth=max_depth, + max_context_tokens=max_context_tokens, + ) + else: + prompt = build_llm_compaction_prompt( + ctx, + accumulator, + formatted, + max_context_tokens=max_context_tokens, + ) + summary_budget = max(1024, max_context_tokens // 2) + try: + response = await ctx.llm.acomplete( + messages=[{"role": "user", "content": prompt}], + system=( + "You are a conversation compactor for an AI agent. " + "Write a detailed summary that allows the agent to " + "continue its work. Preserve user-stated rules, " + "constraints, and account/identity preferences verbatim." + ), + max_tokens=summary_budget, + ) + summary = response.content + except Exception as e: + if is_context_too_large_error(e) and len(messages) > 1: + logger.info( + "LLM context too large (depth=%d, msgs=%d) — splitting", + _depth, + len(messages), + ) + summary = await _llm_compact_split( + ctx, + messages, + accumulator, + _depth, + char_limit=char_limit, + max_depth=max_depth, + max_context_tokens=max_context_tokens, + ) + else: + raise + + # Append tool history at top level only + if _depth == 0: + tool_history = extract_tool_call_history(messages) + if tool_history and "TOOLS ALREADY CALLED" not in summary: + summary += "\n\n" + tool_history + + return summary + + +async def _llm_compact_split( + ctx: NodeContext, + messages: list, + accumulator: Any | None, + _depth: int, + *, + char_limit: int = LLM_COMPACT_CHAR_LIMIT, + max_depth: int = LLM_COMPACT_MAX_DEPTH, + max_context_tokens: int = 128_000, +) -> str: + """Split messages in half and summarise each half independently.""" + mid = max(1, len(messages) // 2) + s1 = await llm_compact( + ctx, + messages[:mid], + None, + _depth + 1, + char_limit=char_limit, + max_depth=max_depth, + max_context_tokens=max_context_tokens, + ) + s2 = await llm_compact( + ctx, + messages[mid:], + accumulator, + _depth + 1, + char_limit=char_limit, + max_depth=max_depth, + max_context_tokens=max_context_tokens, + ) + return s1 + "\n\n" + s2 + + +# --- Compaction helpers ------------------------------------------------ + + +def format_messages_for_summary(messages: list) -> str: + """Format messages as text for LLM summarisation.""" + lines: list[str] = [] + for m in messages: + if m.role == "tool": + content = m.content[:500] + if len(m.content) > 500: + content += "..." + lines.append(f"[tool result]: {content}") + elif m.role == "assistant" and m.tool_calls: + names = [tc.get("function", {}).get("name", "?") for tc in m.tool_calls] + text = m.content[:200] if m.content else "" + lines.append(f"[assistant (calls: {', '.join(names)})]: {text}") + else: + lines.append(f"[{m.role}]: {m.content}") + return "\n\n".join(lines) + + +def build_llm_compaction_prompt( + ctx: NodeContext, + accumulator: Any | None, # OutputAccumulator + formatted_messages: str, + *, + max_context_tokens: int = 128_000, +) -> str: + """Build prompt for LLM compaction targeting 50% of token budget.""" + spec = ctx.node_spec + ctx_lines = [f"NODE: {spec.name} (id={spec.id})"] + if spec.description: + ctx_lines.append(f"PURPOSE: {spec.description}") + if spec.success_criteria: + ctx_lines.append(f"SUCCESS CRITERIA: {spec.success_criteria}") + + if accumulator: + acc = accumulator.to_dict() + done = {k: v for k, v in acc.items() if v is not None} + todo = [k for k, v in acc.items() if v is None] + if done: + ctx_lines.append( + "OUTPUTS ALREADY SET:\n" + + "\n".join(f" {k}: {str(v)[:150]}" for k, v in done.items()) + ) + if todo: + ctx_lines.append(f"OUTPUTS STILL NEEDED: {', '.join(todo)}") + elif spec.output_keys: + ctx_lines.append(f"OUTPUTS STILL NEEDED: {', '.join(spec.output_keys)}") + + target_tokens = max_context_tokens // 2 + target_chars = target_tokens * 4 + node_ctx = "\n".join(ctx_lines) + + return ( + "You are compacting an AI agent's conversation history. " + "The agent is still working and needs to continue.\n\n" + f"AGENT CONTEXT:\n{node_ctx}\n\n" + f"CONVERSATION MESSAGES:\n{formatted_messages}\n\n" + "INSTRUCTIONS:\n" + f"Write a summary of approximately {target_chars} characters " + f"(~{target_tokens} tokens).\n" + "1. Preserve ALL user-stated rules, constraints, and preferences " + "verbatim.\n" + "2. Preserve key decisions made and results obtained.\n" + "3. Preserve in-progress work state so the agent can continue.\n" + "4. Be detailed enough that the agent can resume without " + "re-doing work.\n" + ) + + +async def log_compaction( + ctx: NodeContext, + conversation: NodeConversation, + ratio_before: float, + event_bus: Any | None, +) -> None: + """Log compaction result to runtime logger and event bus.""" + ratio_after = conversation.usage_ratio() + before_pct = round(ratio_before * 100) + after_pct = round(ratio_after * 100) + + # Determine label from what happened + if after_pct >= before_pct - 1: + level = "prune_only" + elif ratio_after <= 0.6: + level = "llm" + else: + level = "structural" + + logger.info( + "Compaction complete (%s): %d%% -> %d%%", + level, + before_pct, + after_pct, + ) + + if ctx.runtime_logger: + ctx.runtime_logger.log_step( + node_id=ctx.node_id, + node_type="event_loop", + step_index=-1, + llm_text=f"Context compacted ({level}): {before_pct}% \u2192 {after_pct}%", + verdict="COMPACTION", + verdict_feedback=f"level={level} before={before_pct}% after={after_pct}%", + ) + + if event_bus: + from framework.runtime.event_bus import AgentEvent, EventType + + await event_bus.publish( + AgentEvent( + type=EventType.CONTEXT_COMPACTED, + stream_id=ctx.stream_id or ctx.node_id, + node_id=ctx.node_id, + data={ + "level": level, + "usage_before": before_pct, + "usage_after": after_pct, + }, + ) + ) + + +def build_emergency_summary( + ctx: NodeContext, + accumulator: Any | None = None, # OutputAccumulator + conversation: NodeConversation | None = None, + config: Any | None = None, # LoopConfig +) -> str: + """Build a structured emergency compaction summary. + + Unlike normal/aggressive compaction which uses an LLM summary, + emergency compaction cannot afford an LLM call (context is already + way over budget). Instead, build a deterministic summary from the + node's known state so the LLM can continue working after + compaction without losing track of its task and inputs. + """ + parts = [ + "EMERGENCY COMPACTION — previous conversation was too large " + "and has been replaced with this summary.\n" + ] + + # 1. Node identity + spec = ctx.node_spec + parts.append(f"NODE: {spec.name} (id={spec.id})") + if spec.description: + parts.append(f"PURPOSE: {spec.description}") + + # 2. Inputs the node received + input_lines = [] + for key in spec.input_keys: + value = ctx.input_data.get(key) or ctx.memory.read(key) + if value is not None: + # Truncate long values but keep them recognisable + v_str = str(value) + if len(v_str) > 200: + v_str = v_str[:200] + "…" + input_lines.append(f" {key}: {v_str}") + if input_lines: + parts.append("INPUTS:\n" + "\n".join(input_lines)) + + # 3. Output accumulator state (what's been set so far) + if accumulator: + acc_state = accumulator.to_dict() + set_keys = {k: v for k, v in acc_state.items() if v is not None} + missing = [k for k, v in acc_state.items() if v is None] + if set_keys: + lines = [f" {k}: {str(v)[:150]}" for k, v in set_keys.items()] + parts.append("OUTPUTS ALREADY SET:\n" + "\n".join(lines)) + if missing: + parts.append(f"OUTPUTS STILL NEEDED: {', '.join(missing)}") + elif spec.output_keys: + parts.append(f"OUTPUTS STILL NEEDED: {', '.join(spec.output_keys)}") + + # 4. Available tools reminder + if spec.tools: + parts.append(f"AVAILABLE TOOLS: {', '.join(spec.tools)}") + + # 5. Spillover files — list actual files so the LLM can load + # them immediately instead of having to call list_data_files first. + # Inline adapt.md (agent memory) directly — it contains user rules + # and identity preferences that must survive emergency compaction. + spillover_dir = config.spillover_dir if config else None + if spillover_dir: + try: + from pathlib import Path + + data_dir = Path(spillover_dir) + if data_dir.is_dir(): + # Inline adapt.md content directly + adapt_path = data_dir / "adapt.md" + if adapt_path.is_file(): + adapt_text = adapt_path.read_text(encoding="utf-8").strip() + if adapt_text: + parts.append(f"AGENT MEMORY (adapt.md):\n{adapt_text}") + + all_files = sorted( + f.name for f in data_dir.iterdir() if f.is_file() and f.name != "adapt.md" + ) + # Separate conversation history files from regular data files + conv_files = [f for f in all_files if re.match(r"conversation_\d+\.md$", f)] + data_files = [f for f in all_files if f not in conv_files] + + if conv_files: + conv_list = "\n".join( + f" - {f} (full path: {data_dir / f})" for f in conv_files + ) + parts.append( + "CONVERSATION HISTORY (freeform messages saved during compaction — " + "use load_data('') to review earlier dialogue):\n" + conv_list + ) + if data_files: + file_list = "\n".join( + f" - {f} (full path: {data_dir / f})" for f in data_files[:30] + ) + parts.append( + "DATA FILES (use load_data('') to read):\n" + file_list + ) + if not all_files: + parts.append( + "NOTE: Large tool results may have been saved to files. " + "Use list_directory to check the data directory." + ) + except Exception: + parts.append( + "NOTE: Large tool results were saved to files. " + "Use read_file(path='') to read them." + ) + + # 6. Tool call history (prevent re-calling tools) + if conversation is not None: + tool_history = _extract_tool_call_history(conversation) + if tool_history: + parts.append(tool_history) + + parts.append( + "\nContinue working towards setting the remaining outputs. " + "Use your tools and the inputs above." + ) + return "\n\n".join(parts) + + +def _extract_tool_call_history(conversation: NodeConversation) -> str: + """Extract tool call history from conversation messages. + + This is the instance-level variant that operates on a NodeConversation + directly (vs. the module-level extract_tool_call_history in conversation.py + which works on raw message lists). + """ + from framework.graph.conversation import extract_tool_call_history + return extract_tool_call_history(list(conversation.messages)) diff --git a/core/framework/graph/event_loop/cursor_persistence.py b/core/framework/graph/event_loop/cursor_persistence.py new file mode 100644 index 0000000000..122edaf185 --- /dev/null +++ b/core/framework/graph/event_loop/cursor_persistence.py @@ -0,0 +1,219 @@ +"""Cursor persistence, queue draining, and pause detection. + +Handles the checkpoint/resume cycle: restoring state from a previous +conversation store, writing cursor data, and managing injection/trigger +queues between iterations. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass +from typing import Any + +from framework.graph.conversation import NodeConversation +from framework.graph.node import NodeContext + +logger = logging.getLogger(__name__) + + +@dataclass +class RestoredState: + """State recovered from a previous checkpoint.""" + + conversation: NodeConversation + accumulator: Any # OutputAccumulator + start_iteration: int + recent_responses: list[str] + recent_tool_fingerprints: list[list[tuple[str, str]]] + + +async def restore( + conversation_store: Any | None, # ConversationStore + ctx: NodeContext, + config: Any, # LoopConfig +) -> RestoredState | None: + """Attempt to restore from a previous checkpoint. + + Returns a ``RestoredState`` with conversation, accumulator, iteration + counter, and stall/doom-loop detection state — everything needed to + resume exactly where execution stopped. + """ + if conversation_store is None: + return None + + # Import here to avoid circular imports at module level + from framework.graph.event_loop_node import OutputAccumulator + + # In isolated mode, filter parts by phase_id so the node only sees + # its own messages in the shared flat conversation store. In + # continuous mode (or when _restore is called for timer-resume) + # load all parts — the full conversation threads across nodes. + _is_continuous = getattr(ctx, "continuous_mode", False) + phase_filter = None if _is_continuous else ctx.node_id + conversation = await NodeConversation.restore( + conversation_store, + phase_id=phase_filter, + ) + if conversation is None: + return None + + accumulator = await OutputAccumulator.restore(conversation_store) + accumulator.spillover_dir = config.spillover_dir + accumulator.max_value_chars = config.max_output_value_chars + + cursor = await conversation_store.read_cursor() + start_iteration = cursor.get("iteration", 0) + 1 if cursor else 0 + + # Restore stall/doom-loop detection state + recent_responses: list[str] = cursor.get("recent_responses", []) if cursor else [] + raw_fps = cursor.get("recent_tool_fingerprints", []) if cursor else [] + recent_tool_fingerprints: list[list[tuple[str, str]]] = [ + [tuple(pair) for pair in fps] # type: ignore[misc] + for fps in raw_fps + ] + + logger.info( + f"Restored event loop: iteration={start_iteration}, " + f"messages={conversation.message_count}, " + f"outputs={list(accumulator.values.keys())}, " + f"stall_window={len(recent_responses)}, " + f"doom_window={len(recent_tool_fingerprints)}" + ) + return RestoredState( + conversation=conversation, + accumulator=accumulator, + start_iteration=start_iteration, + recent_responses=recent_responses, + recent_tool_fingerprints=recent_tool_fingerprints, + ) + + +async def write_cursor( + conversation_store: Any | None, # ConversationStore + ctx: NodeContext, + conversation: NodeConversation, + accumulator: Any, # OutputAccumulator + iteration: int, + *, + recent_responses: list[str] | None = None, + recent_tool_fingerprints: list[list[tuple[str, str]]] | None = None, +) -> None: + """Write checkpoint cursor for crash recovery. + + Persists iteration counter, accumulator outputs, and stall/doom-loop + detection state so that resume picks up exactly where execution stopped. + """ + if conversation_store: + cursor = await conversation_store.read_cursor() or {} + cursor.update( + { + "iteration": iteration, + "node_id": ctx.node_id, + "next_seq": conversation.next_seq, + "outputs": accumulator.to_dict(), + } + ) + # Persist stall/doom-loop detection state for reliable resume + if recent_responses is not None: + cursor["recent_responses"] = recent_responses + if recent_tool_fingerprints is not None: + # Convert list[list[tuple]] → list[list[list]] for JSON + cursor["recent_tool_fingerprints"] = [ + [list(pair) for pair in fps] for fps in recent_tool_fingerprints + ] + await conversation_store.write_cursor(cursor) + + +async def drain_injection_queue( + queue: asyncio.Queue, + conversation: NodeConversation, +) -> int: + """Drain all pending injected events as user messages. Returns count.""" + count = 0 + while not queue.empty(): + try: + content, is_client_input = queue.get_nowait() + logger.info( + "[drain] injected message (client_input=%s): %s", + is_client_input, + content[:200] if content else "(empty)", + ) + # Real user input is stored as-is; external events get a prefix + if is_client_input: + await conversation.add_user_message(content, is_client_input=True) + else: + await conversation.add_user_message(f"[External event]: {content}") + count += 1 + except asyncio.QueueEmpty: + break + return count + + +async def drain_trigger_queue( + queue: asyncio.Queue, + conversation: NodeConversation, +) -> int: + """Drain all pending trigger events as a single batched user message. + + Multiple triggers are merged so the LLM sees them atomically and can + reason about all pending triggers before acting. + """ + # Import here to avoid circular imports at module level + from framework.graph.event_loop_node import TriggerEvent + + triggers: list[TriggerEvent] = [] + while not queue.empty(): + try: + triggers.append(queue.get_nowait()) + except asyncio.QueueEmpty: + break + + if not triggers: + return 0 + + parts: list[str] = [] + for t in triggers: + task = t.payload.get("task", "") + task_line = f"\nTask: {task}" if task else "" + payload_str = json.dumps(t.payload, default=str) + parts.append(f"[TRIGGER: {t.trigger_type}/{t.source_id}]{task_line}\n{payload_str}") + + combined = "\n\n".join(parts) + logger.info("[drain] %d trigger(s): %s", len(triggers), combined[:200]) + await conversation.add_user_message(combined) + return len(triggers) + + +async def check_pause( + ctx: NodeContext, + conversation: NodeConversation, + iteration: int, +) -> bool: + """ + Check if pause has been requested. Returns True if paused. + + Note: This check happens BEFORE starting iteration N, after completing N-1. + If paused, the node exits having completed {iteration} iterations (0 to iteration-1). + """ + # Check executor-level pause event (for /pause command, Ctrl+Z) + if ctx.pause_event and ctx.pause_event.is_set(): + completed = iteration # 0-indexed: iteration=3 means 3 iterations completed (0,1,2) + logger.info(f"⏸ Pausing after {completed} iteration(s) completed (executor-level)") + return True + + # Check context-level pause flags (legacy/alternative methods) + pause_requested = ctx.input_data.get("pause_requested", False) + if not pause_requested: + try: + pause_requested = ctx.memory.read("pause_requested") or False + except (PermissionError, KeyError): + pause_requested = False + if pause_requested: + completed = iteration + logger.info(f"⏸ Pausing after {completed} iteration(s) completed (context-level)") + return True + + return False diff --git a/core/framework/graph/event_loop/event_publishing.py b/core/framework/graph/event_loop/event_publishing.py new file mode 100644 index 0000000000..eabc9283b3 --- /dev/null +++ b/core/framework/graph/event_loop/event_publishing.py @@ -0,0 +1,332 @@ +"""EventBus publishing helpers for the event loop. + +Thin wrappers around EventBus.emit_*() calls that check for bus existence +before publishing. Extracted to reduce noise in the main orchestrator. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from framework.graph.node import NodeContext +from framework.runtime.event_bus import EventBus + +logger = logging.getLogger(__name__) + + +async def publish_loop_started( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + max_iterations: int, + execution_id: str = "", +) -> None: + if event_bus: + await event_bus.emit_node_loop_started( + stream_id=stream_id, + node_id=node_id, + max_iterations=max_iterations, + execution_id=execution_id, + ) + + +async def generate_action_plan( + event_bus: EventBus | None, + ctx: NodeContext, + stream_id: str, + node_id: str, + execution_id: str, +) -> None: + """Generate a brief action plan via LLM and emit it as an SSE event. + + Runs as a fire-and-forget task so it never blocks the main loop. + """ + try: + system_prompt = ctx.node_spec.system_prompt or "" + # Trim to keep the prompt small + prompt_summary = system_prompt[:500] + if len(system_prompt) > 500: + prompt_summary += "..." + + tool_names = [t.name for t in ctx.available_tools] + output_keys = ctx.node_spec.output_keys or [] + + prompt = ( + f'You are about to work on a task as node "{node_id}".\n\n' + f"System prompt:\n{prompt_summary}\n\n" + f"Tools available: {tool_names}\n" + f"Required outputs: {output_keys}\n\n" + f"Write a brief action plan (2-5 bullet points) describing " + f"what you will do to complete this task. Be specific and concise.\n" + f"Return ONLY the plan text, no preamble." + ) + + response = await ctx.llm.acomplete( + messages=[{"role": "user", "content": prompt}], + max_tokens=1024, + ) + + plan = response.content.strip() + if plan and event_bus: + await event_bus.emit_node_action_plan( + stream_id=stream_id, + node_id=node_id, + plan=plan, + execution_id=execution_id, + ) + except Exception as e: + logger.warning("Action plan generation failed for node '%s': %s", node_id, e) + + +async def publish_iteration( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + iteration: int, + execution_id: str = "", + extra_data: dict | None = None, +) -> None: + if event_bus: + await event_bus.emit_node_loop_iteration( + stream_id=stream_id, + node_id=node_id, + iteration=iteration, + execution_id=execution_id, + extra_data=extra_data, + ) + + +async def publish_llm_turn_complete( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + stop_reason: str, + model: str, + input_tokens: int, + output_tokens: int, + cached_tokens: int = 0, + execution_id: str = "", + iteration: int | None = None, +) -> None: + if event_bus: + await event_bus.emit_llm_turn_complete( + stream_id=stream_id, + node_id=node_id, + stop_reason=stop_reason, + model=model, + input_tokens=input_tokens, + output_tokens=output_tokens, + cached_tokens=cached_tokens, + execution_id=execution_id, + iteration=iteration, + ) + + +def log_skip_judge( + ctx: NodeContext, + node_id: str, + iteration: int, + feedback: str, + tool_calls: list[dict], + llm_text: str, + turn_tokens: dict[str, int], + iter_start: float, +) -> None: + """Log a CONTINUE step that skips judge evaluation (e.g., waiting for input).""" + if ctx.runtime_logger: + ctx.runtime_logger.log_step( + node_id=node_id, + node_type="event_loop", + step_index=iteration, + verdict="CONTINUE", + verdict_feedback=feedback, + tool_calls=tool_calls, + llm_text=llm_text, + input_tokens=turn_tokens.get("input", 0), + output_tokens=turn_tokens.get("output", 0), + latency_ms=int((time.time() - iter_start) * 1000), + ) + + +async def publish_loop_completed( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + iterations: int, + execution_id: str = "", +) -> None: + if event_bus: + await event_bus.emit_node_loop_completed( + stream_id=stream_id, + node_id=node_id, + iterations=iterations, + execution_id=execution_id, + ) + + +async def publish_stalled( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + execution_id: str = "", +) -> None: + if event_bus: + await event_bus.emit_node_stalled( + stream_id=stream_id, + node_id=node_id, + reason="Consecutive similar responses detected", + execution_id=execution_id, + ) + + +async def publish_text_delta( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + content: str, + snapshot: str, + ctx: NodeContext, + execution_id: str = "", + iteration: int | None = None, + inner_turn: int = 0, +) -> None: + if event_bus: + if ctx.node_spec.client_facing: + await event_bus.emit_client_output_delta( + stream_id=stream_id, + node_id=node_id, + content=content, + snapshot=snapshot, + execution_id=execution_id, + iteration=iteration, + inner_turn=inner_turn, + ) + else: + await event_bus.emit_llm_text_delta( + stream_id=stream_id, + node_id=node_id, + content=content, + snapshot=snapshot, + execution_id=execution_id, + inner_turn=inner_turn, + ) + + +async def publish_tool_started( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + tool_use_id: str, + tool_name: str, + tool_input: dict, + execution_id: str = "", +) -> None: + if event_bus: + await event_bus.emit_tool_call_started( + stream_id=stream_id, + node_id=node_id, + tool_use_id=tool_use_id, + tool_name=tool_name, + tool_input=tool_input, + execution_id=execution_id, + ) + + +async def publish_tool_completed( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + tool_use_id: str, + tool_name: str, + result: str, + is_error: bool, + execution_id: str = "", +) -> None: + if event_bus: + await event_bus.emit_tool_call_completed( + stream_id=stream_id, + node_id=node_id, + tool_use_id=tool_use_id, + tool_name=tool_name, + result=result, + is_error=is_error, + execution_id=execution_id, + ) + + +async def publish_judge_verdict( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + action: str, + feedback: str = "", + judge_type: str = "implicit", + iteration: int = 0, + execution_id: str = "", +) -> None: + if event_bus: + await event_bus.emit_judge_verdict( + stream_id=stream_id, + node_id=node_id, + action=action, + feedback=feedback, + judge_type=judge_type, + iteration=iteration, + execution_id=execution_id, + ) + + +async def publish_output_key_set( + event_bus: EventBus | None, + stream_id: str, + node_id: str, + key: str, + execution_id: str = "", +) -> None: + if event_bus: + await event_bus.emit_output_key_set( + stream_id=stream_id, node_id=node_id, key=key, execution_id=execution_id + ) + + +async def run_hooks( + hooks_config: dict[str, list], + event: str, + conversation: Any, # NodeConversation + trigger: str | None = None, +) -> None: + """Run all registered hooks for *event*, applying their results. + + Each hook receives a HookContext and may return a HookResult that: + - replaces the system prompt (result.system_prompt) + - injects an extra user message (result.inject) + Hooks run in registration order; each sees the prompt as left by the + previous hook. + """ + # Import here to avoid circular deps at module level + from framework.graph.event_loop_node import HookContext + + hook_list = hooks_config.get(event, []) + if not hook_list: + return + for hook in hook_list: + ctx = HookContext( + event=event, + trigger=trigger, + system_prompt=conversation.system_prompt, + ) + try: + result = await hook(ctx) + except Exception: + logger.warning( + "Hook '%s' raised an exception", event, exc_info=True + ) + continue + if result is None: + continue + if result.system_prompt: + conversation.update_system_prompt(result.system_prompt) + if result.inject: + await conversation.add_user_message(result.inject) diff --git a/core/framework/graph/event_loop/judge_pipeline.py b/core/framework/graph/event_loop/judge_pipeline.py new file mode 100644 index 0000000000..0cd3e2cc96 --- /dev/null +++ b/core/framework/graph/event_loop/judge_pipeline.py @@ -0,0 +1,144 @@ +"""Judge evaluation pipeline for the event loop. + +Contains the turn evaluation logic (implicit judge + custom judge dispatch) +and the SubagentJudge implementation. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from framework.graph.conversation import NodeConversation +from framework.graph.node import NodeContext + +logger = logging.getLogger(__name__) + + +async def judge_turn( + *, + mark_complete_flag: bool, + judge: Any | None, # JudgeProtocol + ctx: NodeContext, + conversation: NodeConversation, + accumulator: Any, # OutputAccumulator + assistant_text: str, + tool_results: list[dict], + iteration: int, + get_missing_output_keys_fn: Any, # Callable + max_context_tokens: int, +) -> Any: # JudgeVerdict + """Evaluate the current state using judge or implicit logic. + + Evaluation levels (in order): + 0. Short-circuits: mark_complete, skip_judge, tool-continue. + 1. Custom judge (JudgeProtocol) — full authority when set. + 2. Implicit judge — output-key check + optional conversation-aware + quality gate (when ``success_criteria`` is defined). + + Returns a JudgeVerdict. ``feedback=None`` means no real evaluation + happened (skip_judge, tool-continue); the caller must not inject a + feedback message. Any non-None feedback (including ``""``) means a + real evaluation occurred and will be logged into the conversation. + """ + from framework.graph.event_loop_node import JudgeVerdict + + # --- Level 0: short-circuits (no evaluation) ----------------------- + + if mark_complete_flag: + return JudgeVerdict(action="ACCEPT") + + if ctx.node_spec.skip_judge: + return JudgeVerdict(action="RETRY") # feedback=None → not logged + + # --- Level 1: custom judge ----------------------------------------- + + if judge is not None: + context = { + "assistant_text": assistant_text, + "tool_calls": tool_results, + "output_accumulator": accumulator.to_dict(), + "accumulator": accumulator, + "iteration": iteration, + "conversation_summary": conversation.export_summary(), + "output_keys": ctx.node_spec.output_keys, + "missing_keys": get_missing_output_keys_fn( + accumulator, ctx.node_spec.output_keys, ctx.node_spec.nullable_output_keys + ), + } + verdict = await judge.evaluate(context) + # Ensure evaluated RETRY always carries feedback for logging. + if verdict.action == "RETRY" and not verdict.feedback: + return JudgeVerdict(action="RETRY", feedback="Custom judge returned RETRY.") + return verdict + + # --- Level 2: implicit judge --------------------------------------- + + # Real tool calls were made — let the agent keep working. + if tool_results: + return JudgeVerdict(action="RETRY") # feedback=None → not logged + + missing = get_missing_output_keys_fn( + accumulator, ctx.node_spec.output_keys, ctx.node_spec.nullable_output_keys + ) + + if missing: + return JudgeVerdict( + action="RETRY", + feedback=( + f"Task incomplete. Required outputs not yet produced: {missing}. " + f"Follow your system prompt instructions to complete the work." + ), + ) + + # All output keys present — run safety checks before accepting. + + output_keys = ctx.node_spec.output_keys or [] + nullable_keys = set(ctx.node_spec.nullable_output_keys or []) + + # All-nullable with nothing set → node produced nothing useful. + all_nullable = output_keys and nullable_keys >= set(output_keys) + none_set = not any(accumulator.get(k) is not None for k in output_keys) + if all_nullable and none_set: + return JudgeVerdict( + action="RETRY", + feedback=( + f"No output keys have been set yet. " + f"Use set_output to set at least one of: {output_keys}" + ), + ) + + # Client-facing with no output keys → continuous interaction node. + # Inject tool-use pressure instead of auto-accepting. + if not output_keys and ctx.node_spec.client_facing: + return JudgeVerdict( + action="RETRY", + feedback=( + "STOP describing what you will do. " + "You have FULL access to all tools — file creation, " + "shell commands, MCP tools — and you CAN call them " + "directly in your response. Respond ONLY with tool " + "calls, no prose. Execute the task now." + ), + ) + + # Level 2b: conversation-aware quality check (if success_criteria set) + if ctx.node_spec.success_criteria and ctx.llm: + from framework.graph.conversation_judge import evaluate_phase_completion + + verdict = await evaluate_phase_completion( + llm=ctx.llm, + conversation=conversation, + phase_name=ctx.node_spec.name, + phase_description=ctx.node_spec.description, + success_criteria=ctx.node_spec.success_criteria, + accumulator_state=accumulator.to_dict(), + max_context_tokens=max_context_tokens, + ) + if verdict.action != "ACCEPT": + return JudgeVerdict( + action=verdict.action, + feedback=verdict.feedback or "Phase criteria not met.", + ) + + return JudgeVerdict(action="ACCEPT", feedback="") diff --git a/core/framework/graph/event_loop/stall_detector.py b/core/framework/graph/event_loop/stall_detector.py new file mode 100644 index 0000000000..61fe9c2343 --- /dev/null +++ b/core/framework/graph/event_loop/stall_detector.py @@ -0,0 +1,106 @@ +"""Stall and doom-loop detection for the event loop. + +Pure functions with no class dependencies — safe to call from any context. +""" + +from __future__ import annotations + +import json + + +def ngram_similarity(s1: str, s2: str, n: int = 2) -> float: + """Jaccard similarity of n-gram sets. + + Returns 0.0-1.0, where 1.0 is exact match. + Fast: O(len(s) + len(s2)) using set operations. + """ + + def _ngrams(s: str) -> set[str]: + return {s[i : i + n] for i in range(len(s) - n + 1) if s.strip()} + + if not s1 or not s2: + return 0.0 + + ngrams1, ngrams2 = _ngrams(s1.lower()), _ngrams(s2.lower()) + if not ngrams1 or not ngrams2: + return 0.0 + + intersection = len(ngrams1 & ngrams2) + union = len(ngrams1 | ngrams2) + return intersection / union if union else 0.0 + + +def is_stalled( + recent_responses: list[str], + threshold: int, + similarity_threshold: float, +) -> bool: + """Detect stall using n-gram similarity. + + Detects when ALL N consecutive responses are mutually similar + (>= threshold). A single dissimilar response resets the signal. + This catches phrases like "I'm still stuck" vs "I'm stuck" + without false-positives on "attempt 1" vs "attempt 2". + """ + if len(recent_responses) < threshold: + return False + if not recent_responses[0]: + return False + + # Every consecutive pair must be similar + for i in range(1, len(recent_responses)): + if ngram_similarity(recent_responses[i], recent_responses[i - 1]) < similarity_threshold: + return False + return True + + +def fingerprint_tool_calls( + tool_results: list[dict], +) -> list[tuple[str, str]]: + """Create deterministic fingerprints for a turn's tool calls. + + Each fingerprint is (tool_name, canonical_args_json). Order-sensitive + so [search("a"), fetch("b")] != [fetch("b"), search("a")]. + """ + fingerprints = [] + for tr in tool_results: + name = tr.get("tool_name", "") + args = tr.get("tool_input", {}) + try: + canonical = json.dumps(args, sort_keys=True, default=str) + except (TypeError, ValueError): + canonical = str(args) + fingerprints.append((name, canonical)) + return fingerprints + + +def is_tool_doom_loop( + recent_tool_fingerprints: list[list[tuple[str, str]]], + threshold: int, + enabled: bool = True, +) -> tuple[bool, str]: + """Detect doom loop via exact fingerprint match. + + Detects when N consecutive turns invoke the same tools with + identical (canonicalized) arguments. Different arguments mean + different work, so only exact matches count. + + Returns (is_doom_loop, description). + """ + if not enabled: + return False, "" + if len(recent_tool_fingerprints) < threshold: + return False, "" + first = recent_tool_fingerprints[0] + if not first: + return False, "" + + # All turns in the window must match the first exactly + if all(fp == first for fp in recent_tool_fingerprints[1:]): + tool_names = [name for name, _ in first] + desc = ( + f"Doom loop detected: {len(recent_tool_fingerprints)} " + f"identical consecutive tool calls ({', '.join(tool_names)})" + ) + return True, desc + return False, "" diff --git a/core/framework/graph/event_loop/subagent_executor.py b/core/framework/graph/event_loop/subagent_executor.py new file mode 100644 index 0000000000..104544c2bd --- /dev/null +++ b/core/framework/graph/event_loop/subagent_executor.py @@ -0,0 +1,397 @@ +"""Subagent execution for the event loop. + +Handles the full subagent lifecycle: validation, context setup, tool filtering, +conversation store derivation, execution, and cleanup. Also includes the +_EscalationReceiver helper used for subagent → queen escalation routing. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +from pathlib import Path +from typing import Any + +from framework.llm.provider import ToolResult, ToolUse + +logger = logging.getLogger(__name__) + + +class EscalationReceiver: + """Temporary receiver registered in node_registry for subagent escalation routing. + + When a subagent calls ``report_to_parent(wait_for_response=True)``, the callback + creates one of these, registers it under a unique escalation ID in the executor's + ``node_registry``, and awaits ``wait()``. The TUI / runner calls + ``inject_input(escalation_id, content)`` which the ``ExecutionStream`` routes here + via ``inject_event()`` — matching the same ``hasattr(node, "inject_event")`` check + used for regular ``EventLoopNode`` instances. + """ + + def __init__(self) -> None: + self._event = asyncio.Event() + self._response: str | None = None + self._awaiting_input = True # So inject_worker_message() can prefer us + + async def inject_event(self, content: str, *, is_client_input: bool = False) -> None: + """Called by ExecutionStream.inject_input() when the user responds.""" + self._response = content + self._event.set() + + async def wait(self) -> str | None: + """Block until inject_event() delivers the user's response.""" + await self._event.wait() + return self._response + + +async def execute_subagent( + ctx: Any, # NodeContext + agent_id: str, + task: str, + *, + accumulator: Any | None = None, # OutputAccumulator + event_bus: Any | None = None, # EventBus + config: Any = None, # LoopConfig + tool_executor: Any | None = None, # Callable + conversation_store: Any | None = None, # ConversationStore + subagent_instance_counter: dict[str, int] | None = None, +) -> ToolResult: + """Execute a subagent and return the result as a ToolResult. + + The subagent: + - Gets a fresh conversation with just the task + - Has read-only access to the parent's readable memory + - Cannot delegate to its own subagents (prevents recursion) + - Returns its output in structured JSON format + + Args: + ctx: Parent node's context (for memory, tools, LLM access). + agent_id: The node ID of the subagent to invoke. + task: The task description to give the subagent. + accumulator: Parent's OutputAccumulator. + event_bus: EventBus for lifecycle events. + config: LoopConfig for iteration/tool limits. + tool_executor: Tool executor callable. + conversation_store: Parent conversation store (for deriving subagent store). + subagent_instance_counter: Mutable counter dict for unique subagent paths. + + Returns: + ToolResult with structured JSON output. + """ + from framework.graph.node import NodeContext, SharedMemory + + # Log subagent invocation start + logger.info( + "\n" + "=" * 60 + "\n" + "🤖 SUBAGENT INVOCATION\n" + "=" * 60 + "\n" + "Parent Node: %s\n" + "Subagent ID: %s\n" + "Task: %s\n" + "=" * 60, + ctx.node_id, + agent_id, + task[:500] + "..." if len(task) > 500 else task, + ) + + # 1. Validate agent exists in registry + if agent_id not in ctx.node_registry: + return ToolResult( + tool_use_id="", + content=json.dumps( + { + "message": f"Sub-agent '{agent_id}' not found in registry", + "data": None, + "metadata": {"agent_id": agent_id, "success": False, "error": "not_found"}, + } + ), + is_error=True, + ) + + subagent_spec = ctx.node_registry[agent_id] + + # 2. Create read-only memory snapshot + parent_data = ctx.memory.read_all() + + # Merge in-flight outputs from the parent's accumulator. + if accumulator: + for key, value in accumulator.to_dict().items(): + if key not in parent_data: + parent_data[key] = value + + subagent_memory = SharedMemory() + for key, value in parent_data.items(): + subagent_memory.write(key, value, validate=False) + + read_keys = set(parent_data.keys()) | set(subagent_spec.input_keys or []) + scoped_memory = subagent_memory.with_permissions( + read_keys=list(read_keys), + write_keys=[], # Read-only! + ) + + # 2b. Set up report callback (one-way channel to parent / event bus) + subagent_reports: list[dict] = [] + + async def _report_callback( + message: str, + data: dict | None = None, + *, + wait_for_response: bool = False, + ) -> str | None: + subagent_reports.append({"message": message, "data": data, "timestamp": time.time()}) + if event_bus: + await event_bus.emit_subagent_report( + stream_id=ctx.node_id, + node_id=f"{ctx.node_id}:subagent:{agent_id}", + subagent_id=agent_id, + message=message, + data=data, + execution_id=ctx.execution_id, + ) + + if not wait_for_response: + return None + + if not event_bus: + logger.warning( + "Subagent '%s' requested user response but no event_bus available", + agent_id, + ) + return None + + # Create isolated receiver and register for input routing + import uuid + + from framework.graph.event_loop_node import _EscalationReceiver + + escalation_id = f"{ctx.node_id}:escalation:{uuid.uuid4().hex[:8]}" + receiver = _EscalationReceiver() + registry = ctx.shared_node_registry + + registry[escalation_id] = receiver + try: + await event_bus.emit_escalation_requested( + stream_id=ctx.stream_id or ctx.node_id, + node_id=escalation_id, + reason=f"Subagent report (wait_for_response) from {agent_id}", + context=message, + execution_id=ctx.execution_id, + ) + # Block until queen responds + return await receiver.wait() + finally: + registry.pop(escalation_id, None) + + # 3. Filter tools for subagent + subagent_tool_names = set(subagent_spec.tools or []) + tool_source = ctx.all_tools if ctx.all_tools else ctx.available_tools + + # GCU auto-population + if subagent_spec.node_type == "gcu" and not subagent_tool_names: + subagent_tools = [t for t in tool_source if t.name != "delegate_to_sub_agent"] + else: + subagent_tools = [ + t + for t in tool_source + if t.name in subagent_tool_names and t.name != "delegate_to_sub_agent" + ] + + missing = subagent_tool_names - {t.name for t in subagent_tools} + if missing: + logger.warning( + "Subagent '%s' requested tools not found in catalog: %s", + agent_id, + sorted(missing), + ) + + logger.info( + "📦 Subagent '%s' configuration:\n" + " - System prompt: %s\n" + " - Tools available (%d): %s\n" + " - Memory keys inherited: %s", + agent_id, + (subagent_spec.system_prompt[:200] + "...") + if subagent_spec.system_prompt and len(subagent_spec.system_prompt) > 200 + else subagent_spec.system_prompt, + len(subagent_tools), + [t.name for t in subagent_tools], + list(parent_data.keys()), + ) + + # 4. Build subagent context + max_iter = min(config.max_iterations, 10) + subagent_ctx = NodeContext( + runtime=ctx.runtime, + node_id=f"{ctx.node_id}:subagent:{agent_id}", + node_spec=subagent_spec, + memory=scoped_memory, + input_data={"task": task, **parent_data}, + llm=ctx.llm, + available_tools=subagent_tools, + goal_context=( + f"Your specific task: {task}\n\n" + f"COMPLETION REQUIREMENTS:\n" + f"When your task is done, you MUST call set_output() " + f"for each required key: {subagent_spec.output_keys}\n" + f"Alternatively, call report_to_parent(mark_complete=true) " + f"with your findings in message/data.\n" + f"You have a maximum of {max_iter} turns to complete this task." + ), + goal=ctx.goal, + max_tokens=ctx.max_tokens, + runtime_logger=ctx.runtime_logger, + is_subagent_mode=True, # Prevents nested delegation + report_callback=_report_callback, + node_registry={}, # Empty - no nested subagents + shared_node_registry=ctx.shared_node_registry, # For escalation routing + ) + + # 5. Create and execute subagent EventLoopNode + if subagent_instance_counter is not None: + subagent_instance_counter.setdefault(agent_id, 0) + subagent_instance_counter[agent_id] += 1 + subagent_instance = str(subagent_instance_counter[agent_id]) + else: + subagent_instance = "1" + + subagent_conv_store = None + if conversation_store is not None: + from framework.storage.conversation_store import FileConversationStore + + parent_base = getattr(conversation_store, "_base", None) + if parent_base is not None: + conversations_dir = parent_base.parent + subagent_dir_name = f"{agent_id}-{subagent_instance}" + subagent_store_path = conversations_dir / subagent_dir_name + subagent_conv_store = FileConversationStore(base_path=subagent_store_path) + + # Derive a subagent-scoped spillover dir + subagent_spillover = None + if config.spillover_dir: + subagent_spillover = str( + Path(config.spillover_dir) / agent_id / subagent_instance + ) + + # Import here to avoid circular imports at module level + from framework.graph.event_loop_node import EventLoopNode, LoopConfig, SubagentJudge + + subagent_node = EventLoopNode( + event_bus=event_bus, + judge=SubagentJudge(task=task, max_iterations=max_iter), + config=LoopConfig( + max_iterations=max_iter, + max_tool_calls_per_turn=config.max_tool_calls_per_turn, + tool_call_overflow_margin=config.tool_call_overflow_margin, + max_context_tokens=config.max_context_tokens, + stall_detection_threshold=config.stall_detection_threshold, + max_tool_result_chars=config.max_tool_result_chars, + spillover_dir=subagent_spillover, + ), + tool_executor=tool_executor, + conversation_store=subagent_conv_store, + ) + + # Inject a unique GCU browser profile for this subagent + _profile_token = None + try: + from gcu.browser.session import set_active_profile as _set_gcu_profile + + _profile_token = _set_gcu_profile(f"{agent_id}-{subagent_instance}") + except ImportError: + pass # GCU tools not installed; no-op + + try: + logger.info("🚀 Starting subagent '%s' execution...", agent_id) + start_time = time.time() + result = await subagent_node.execute(subagent_ctx) + latency_ms = int((time.time() - start_time) * 1000) + + separator = "-" * 60 + logger.info( + "\n%s\n" + "✅ SUBAGENT '%s' COMPLETED\n" + "%s\n" + "Success: %s\n" + "Latency: %dms\n" + "Tokens used: %s\n" + "Output keys: %s\n" + "%s", + separator, + agent_id, + separator, + result.success, + latency_ms, + result.tokens_used, + list(result.output.keys()) if result.output else [], + separator, + ) + + result_json = { + "message": ( + f"Sub-agent '{agent_id}' completed successfully" + if result.success + else f"Sub-agent '{agent_id}' failed: {result.error}" + ), + "data": result.output, + "reports": subagent_reports if subagent_reports else None, + "metadata": { + "agent_id": agent_id, + "success": result.success, + "tokens_used": result.tokens_used, + "latency_ms": latency_ms, + "report_count": len(subagent_reports), + }, + } + + return ToolResult( + tool_use_id="", + content=json.dumps(result_json, indent=2, default=str), + is_error=not result.success, + ) + + except Exception as e: + logger.exception( + "\n" + "!" * 60 + "\n❌ SUBAGENT '%s' FAILED\nError: %s\n" + "!" * 60, + agent_id, + str(e), + ) + result_json = { + "message": f"Sub-agent '{agent_id}' raised exception: {e}", + "data": None, + "metadata": { + "agent_id": agent_id, + "success": False, + "error": str(e), + }, + } + return ToolResult( + tool_use_id="", + content=json.dumps(result_json, indent=2), + is_error=True, + ) + finally: + # Restore the GCU profile context + if _profile_token is not None: + from gcu.browser.session import _active_profile as _gcu_profile_var + + _gcu_profile_var.reset(_profile_token) + + # Stop the browser session for this subagent's profile + if tool_executor is not None: + _subagent_profile = f"{agent_id}-{subagent_instance}" + try: + _stop_use = ToolUse( + id="gcu-cleanup", + name="browser_stop", + input={"profile": _subagent_profile}, + ) + _stop_result = tool_executor(_stop_use) + if asyncio.iscoroutine(_stop_result) or asyncio.isfuture(_stop_result): + await _stop_result + except Exception as _gcu_exc: + logger.warning( + "GCU browser_stop failed for profile %r: %s", + _subagent_profile, + _gcu_exc, + ) diff --git a/core/framework/graph/event_loop/synthetic_tools.py b/core/framework/graph/event_loop/synthetic_tools.py new file mode 100644 index 0000000000..6320eeb903 --- /dev/null +++ b/core/framework/graph/event_loop/synthetic_tools.py @@ -0,0 +1,371 @@ +"""Synthetic tool builders for the event loop. + +Factory functions that create ``Tool`` definitions for framework-level +synthetic tools (set_output, ask_user, escalate, delegate, report_to_parent). +Also includes the ``handle_set_output`` validation logic. + +All functions are pure — they receive explicit parameters and return +``Tool`` or ``ToolResult`` objects with no side effects. +""" + +from __future__ import annotations + +from typing import Any + +from framework.llm.provider import Tool, ToolResult + + +def build_ask_user_tool() -> Tool: + """Build the synthetic ask_user tool for explicit user-input requests. + + Client-facing nodes call ask_user() when they need to pause and wait + for user input. Text-only turns WITHOUT ask_user flow through without + blocking, allowing progress updates and summaries to stream freely. + """ + return Tool( + name="ask_user", + description=( + "You MUST call this tool whenever you need the user's response. " + "Always call it after greeting the user, asking a question, or " + "requesting approval. Do NOT call it for status updates or " + "summaries that don't require a response. " + "Always include 2-3 predefined options. The UI automatically " + "appends an 'Other' free-text input after your options, so NEVER " + "include catch-all options like 'Custom idea', 'Something else', " + "'Other', or 'None of the above' — the UI handles that. " + "When the question primarily needs a typed answer but you must " + "include options, make one option signal that typing is expected " + "(e.g. 'I\\'ll type my response'). This helps users discover the " + "free-text input. " + "The ONLY exception: omit options when the question demands a " + "free-form answer the user must type out (e.g. 'Describe your " + "agent idea', 'Paste the error message'). " + '{"question": "What would you like to do?", "options": ' + '["Build a new agent", "Modify existing agent", "Run tests"]} ' + "Free-form example: " + '{"question": "Describe the agent you want to build."}' + ), + parameters={ + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question or prompt shown to the user.", + }, + "options": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "2-3 specific predefined choices. Include in most cases. " + 'Example: ["Option A", "Option B", "Option C"]. ' + "The UI always appends an 'Other' free-text input, so " + "do NOT include catch-alls like 'Custom idea' or 'Other'. " + "Omit ONLY when the user must type a free-form answer." + ), + "minItems": 2, + "maxItems": 3, + }, + }, + "required": ["question"], + }, + ) + + +def build_ask_user_multiple_tool() -> Tool: + """Build the synthetic ask_user_multiple tool for batched questions. + + Queen-only tool that presents multiple questions at once so the user + can answer them all in a single interaction rather than one at a time. + """ + return Tool( + name="ask_user_multiple", + description=( + "Ask the user multiple questions at once. Use this instead of " + "ask_user when you have 2 or more questions to ask in the same " + "turn — it lets the user answer everything in one go rather than " + "going back and forth. Each question can have its own predefined " + "options (2-3 choices) or be free-form. The UI renders all " + "questions together with a single Submit button. " + "ALWAYS prefer this over ask_user when you have multiple things " + "to clarify. " + "IMPORTANT: Do NOT repeat the questions in your text response — " + "the widget renders them. Keep your text to a brief intro only. " + '{"questions": [' + ' {"id": "scope", "prompt": "What scope?", "options": ["Full", "Partial"]},' + ' {"id": "format", "prompt": "Output format?", "options": ["PDF", "CSV", "JSON"]},' + ' {"id": "details", "prompt": "Any special requirements?"}' + "]}" + ), + parameters={ + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": ( + "Short identifier for this question (used in the response)." + ), + }, + "prompt": { + "type": "string", + "description": "The question text shown to the user.", + }, + "options": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "2-3 predefined choices. The UI appends an " + "'Other' free-text input automatically. " + "Omit only when the user must type a free-form answer." + ), + "minItems": 2, + "maxItems": 3, + }, + }, + "required": ["id", "prompt"], + }, + "minItems": 2, + "maxItems": 8, + "description": "List of questions to present to the user.", + }, + }, + "required": ["questions"], + }, + ) + + +def build_set_output_tool(output_keys: list[str] | None) -> Tool | None: + """Build the synthetic set_output tool for explicit output declaration.""" + if not output_keys: + return None + return Tool( + name="set_output", + description=( + "Set an output value for this node. Call once per output key. " + "Use this for brief notes, counts, status, and file references — " + "NOT for large data payloads. When a tool result was saved to a " + "data file, pass the filename as the value " + "(e.g. 'google_sheets_get_values_1.txt') so the next phase can " + "load the full data. Values exceeding ~2000 characters are " + "auto-saved to data files. " + f"Valid keys: {output_keys}" + ), + parameters={ + "type": "object", + "properties": { + "key": { + "type": "string", + "description": f"Output key. Must be one of: {output_keys}", + "enum": output_keys, + }, + "value": { + "type": "string", + "description": ( + "The output value — a brief note, count, status, " + "or data filename reference." + ), + }, + }, + "required": ["key", "value"], + }, + ) + + +def build_escalate_tool() -> Tool: + """Build the synthetic escalate tool for worker -> queen handoff.""" + return Tool( + name="escalate", + description=( + "Escalate to the queen when requesting user input, " + "blocked by errors, missing " + "credentials, or ambiguous constraints that require supervisor " + "guidance. Include a concise reason and optional context. " + "The node will pause until the queen injects guidance." + ), + parameters={ + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": ( + "Short reason for escalation (e.g. 'Tool repeatedly failing')." + ), + }, + "context": { + "type": "string", + "description": "Optional diagnostic details for the queen.", + }, + }, + "required": ["reason"], + }, + ) + + +def build_delegate_tool( + sub_agents: list[str], node_registry: dict[str, Any] +) -> Tool | None: + """Build the synthetic delegate_to_sub_agent tool for subagent invocation. + + Args: + sub_agents: List of node IDs that can be invoked as subagents. + node_registry: Map of node_id -> NodeSpec for looking up subagent descriptions. + + Returns: + Tool definition if sub_agents is non-empty, None otherwise. + """ + if not sub_agents: + return None + + agent_descriptions = [] + for agent_id in sub_agents: + spec = node_registry.get(agent_id) + if spec: + desc = getattr(spec, "description", "(no description)") + agent_descriptions.append(f"- {agent_id}: {desc}") + else: + agent_descriptions.append(f"- {agent_id}: (not found in registry)") + + return Tool( + name="delegate_to_sub_agent", + description=( + "Delegate a task to a specialized sub-agent. The sub-agent runs " + "autonomously with read-only access to current memory and returns " + "its result. Use this to parallelize work or leverage specialized capabilities.\n\n" + "Available sub-agents:\n" + "\n".join(agent_descriptions) + ), + parameters={ + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": f"The sub-agent to invoke. Must be one of: {sub_agents}", + "enum": sub_agents, + }, + "task": { + "type": "string", + "description": ( + "The task description for the sub-agent to execute. " + "Be specific about what you want the sub-agent to do and " + "what information to return." + ), + }, + }, + "required": ["agent_id", "task"], + }, + ) + + +def build_report_to_parent_tool() -> Tool: + """Build the synthetic report_to_parent tool for sub-agent progress reports. + + Sub-agents call this to send one-way progress updates, partial findings, + or status reports to the parent node (and external observers via event bus) + without blocking execution. + + When ``wait_for_response`` is True, the sub-agent blocks until the parent + relays the user's response — used for escalation (e.g. login pages, CAPTCHAs). + + When ``mark_complete`` is True, the sub-agent terminates immediately after + sending the report — no need to call set_output for each output key. + """ + return Tool( + name="report_to_parent", + description=( + "Send a report to the parent agent. By default this is fire-and-forget: " + "the parent receives the report but does not respond. " + "Set wait_for_response=true to BLOCK until the user replies — use this " + "when you need human intervention (e.g. login pages, CAPTCHAs, " + "authentication walls). The user's response is returned as the tool result. " + "Set mark_complete=true to finish your task and terminate immediately " + "after sending the report — use this when your findings are in the " + "message/data fields and you don't need to call set_output." + ), + parameters={ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "A human-readable status or progress message.", + }, + "data": { + "type": "object", + "description": "Optional structured data to include with the report.", + }, + "wait_for_response": { + "type": "boolean", + "description": ( + "If true, block execution until the user responds. " + "Use for escalation scenarios requiring human intervention." + ), + "default": False, + }, + "mark_complete": { + "type": "boolean", + "description": ( + "If true, terminate the sub-agent immediately after sending " + "this report. The report message and data are delivered to the " + "parent as the final result. No set_output calls are needed." + ), + "default": False, + }, + }, + "required": ["message"], + }, + ) + + +def handle_set_output( + tool_input: dict[str, Any], + output_keys: list[str] | None, +) -> ToolResult: + """Handle set_output tool call. Returns ToolResult (sync).""" + import logging + import re + + logger = logging.getLogger(__name__) + + key = tool_input.get("key", "") + value = tool_input.get("value", "") + valid_keys = output_keys or [] + + # Recover from truncated JSON (max_tokens hit mid-argument). + # The _raw key is set by litellm when json.loads fails. + if not key and "_raw" in tool_input: + raw = tool_input["_raw"] + key_match = re.search(r'"key"\s*:\s*"(\w+)"', raw) + if key_match: + key = key_match.group(1) + val_match = re.search(r'"value"\s*:\s*"', raw) + if val_match: + start = val_match.end() + value = raw[start:].rstrip() + for suffix in ('"}\n', '"}', '"'): + if value.endswith(suffix): + value = value[: -len(suffix)] + break + if key: + logger.warning( + "Recovered set_output args from truncated JSON: key=%s, value_len=%d", + key, + len(value), + ) + # Re-inject so the caller sees proper key/value + tool_input["key"] = key + tool_input["value"] = value + + if key not in valid_keys: + return ToolResult( + tool_use_id="", + content=f"Invalid output key '{key}'. Valid keys: {valid_keys}", + is_error=True, + ) + + return ToolResult( + tool_use_id="", + content=f"Output '{key}' set successfully.", + is_error=False, + ) diff --git a/core/framework/graph/event_loop/tool_result_handler.py b/core/framework/graph/event_loop/tool_result_handler.py new file mode 100644 index 0000000000..aadfc6734b --- /dev/null +++ b/core/framework/graph/event_loop/tool_result_handler.py @@ -0,0 +1,515 @@ +"""Tool result handling: truncation, spillover, JSON preview, and execution. + +Manages tool result size limits, file spillover for large results, and +smart JSON previews. Also includes transient error classification and +the context-window-exceeded error detector. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from pathlib import Path +from typing import Any + +from framework.llm.provider import ToolResult, ToolUse +from framework.llm.stream_events import ToolCallEvent + +logger = logging.getLogger(__name__) + +# Pattern for detecting context-window-exceeded errors across LLM providers. +_CONTEXT_TOO_LARGE_RE = re.compile( + r"context.{0,20}(length|window|limit|size)|" + r"too.{0,10}(long|large|many.{0,10}tokens)|" + r"(exceed|exceeds|exceeded).{0,30}(limit|window|context|tokens)|" + r"maximum.{0,20}token|prompt.{0,20}too.{0,10}long", + re.IGNORECASE, +) + + +def is_context_too_large_error(exc: BaseException) -> bool: + """Detect whether an exception indicates the LLM input was too large.""" + cls = type(exc).__name__ + if "ContextWindow" in cls: + return True + return bool(_CONTEXT_TOO_LARGE_RE.search(str(exc))) + + +def is_transient_error(exc: BaseException) -> bool: + """Classify whether an exception is transient (retryable) vs permanent. + + Transient: network errors, rate limits, server errors, timeouts. + Permanent: auth errors, bad requests, context window exceeded. + """ + try: + from litellm.exceptions import ( + APIConnectionError, + BadGatewayError, + InternalServerError, + RateLimitError, + ServiceUnavailableError, + ) + + transient_types: tuple[type[BaseException], ...] = ( + RateLimitError, + APIConnectionError, + InternalServerError, + BadGatewayError, + ServiceUnavailableError, + TimeoutError, + ConnectionError, + OSError, + ) + except ImportError: + transient_types = (TimeoutError, ConnectionError, OSError) + + if isinstance(exc, transient_types): + return True + + # RuntimeError from StreamErrorEvent with "Stream error:" prefix + if isinstance(exc, RuntimeError): + error_str = str(exc).lower() + transient_keywords = [ + "rate limit", + "429", + "timeout", + "connection", + "internal server", + "502", + "503", + "504", + "service unavailable", + "bad gateway", + "overloaded", + "failed to parse tool call", + ] + return any(kw in error_str for kw in transient_keywords) + + return False + + +def extract_json_metadata(parsed: Any, *, _depth: int = 0, _max_depth: int = 3) -> str: + """Return a concise structural summary of parsed JSON. + + Reports key names, value types, and — crucially — array lengths so + the LLM knows how much data exists beyond the preview. + + Returns an empty string for simple scalars. + """ + if _depth >= _max_depth: + if isinstance(parsed, dict): + return f"dict with {len(parsed)} keys" + if isinstance(parsed, list): + return f"list of {len(parsed)} items" + return type(parsed).__name__ + + if isinstance(parsed, dict): + if not parsed: + return "empty dict" + lines: list[str] = [] + indent = " " * (_depth + 1) + for key, value in list(parsed.items())[:20]: + if isinstance(value, list): + line = f'{indent}"{key}": list of {len(value)} items' + if value: + first = value[0] + if isinstance(first, dict): + sample_keys = list(first.keys())[:10] + line += f" (each item: dict with keys {sample_keys})" + elif isinstance(first, list): + line += f" (each item: list of {len(first)} elements)" + lines.append(line) + elif isinstance(value, dict): + child = extract_json_metadata( + value, _depth=_depth + 1, _max_depth=_max_depth + ) + lines.append(f'{indent}"{key}": {child}') + else: + lines.append(f'{indent}"{key}": {type(value).__name__}') + if len(parsed) > 20: + lines.append(f"{indent}... and {len(parsed) - 20} more keys") + return "\n".join(lines) + + if isinstance(parsed, list): + if not parsed: + return "empty list" + desc = f"list of {len(parsed)} items" + first = parsed[0] + if isinstance(first, dict): + sample_keys = list(first.keys())[:10] + desc += f" (each item: dict with keys {sample_keys})" + elif isinstance(first, list): + desc += f" (each item: list of {len(first)} elements)" + return desc + + return "" + + +def build_json_preview(parsed: Any, *, max_chars: int = 5000) -> str | None: + """Build a smart preview of parsed JSON, truncating large arrays. + + Shows first 3 + last 1 items of large arrays with explicit count + markers so the LLM cannot mistake the preview for the full dataset. + + Returns ``None`` if no truncation was needed (no large arrays). + """ + _LARGE_ARRAY_THRESHOLD = 10 + + def _truncate_arrays(obj: Any) -> tuple[Any, bool]: + """Return (truncated_copy, was_truncated).""" + if isinstance(obj, list) and len(obj) > _LARGE_ARRAY_THRESHOLD: + n = len(obj) + head = obj[:3] + tail = obj[-1:] + marker = f"... ({n - 4} more items omitted, {n} total) ..." + return head + [marker] + tail, True + if isinstance(obj, dict): + changed = False + out: dict[str, Any] = {} + for k, v in obj.items(): + new_v, did = _truncate_arrays(v) + out[k] = new_v + changed = changed or did + return (out, True) if changed else (obj, False) + return obj, False + + preview_obj, was_truncated = _truncate_arrays(parsed) + if not was_truncated: + return None # No large arrays — caller should use raw slicing + + try: + result = json.dumps(preview_obj, indent=2, ensure_ascii=False) + except (TypeError, ValueError): + return None + + if len(result) > max_chars: + # Even 3+1 items too big — try just 1 item + def _minimal_arrays(obj: Any) -> Any: + if isinstance(obj, list) and len(obj) > _LARGE_ARRAY_THRESHOLD: + n = len(obj) + return obj[:1] + [f"... ({n - 1} more items omitted, {n} total) ..."] + if isinstance(obj, dict): + return {k: _minimal_arrays(v) for k, v in obj.items()} + return obj + + preview_obj = _minimal_arrays(parsed) + try: + result = json.dumps(preview_obj, indent=2, ensure_ascii=False) + except (TypeError, ValueError): + return None + if len(result) > max_chars: + result = result[:max_chars] + "…" + + return result + + +def truncate_tool_result( + result: ToolResult, + tool_name: str, + *, + max_tool_result_chars: int, + spillover_dir: str | None, + next_spill_filename_fn: Any, # Callable[[str], str] +) -> ToolResult: + """Persist tool result to file and optionally truncate for context. + + When *spillover_dir* is configured, EVERY non-error tool result is + saved to a file (short filename like ``web_search_1.txt``). A + ``[Saved to '...']`` annotation is appended so the reference + survives pruning and compaction. + + - Small results (≤ limit): full content kept + file annotation + - Large results (> limit): preview + file reference + - Errors: pass through unchanged + - load_data results: truncate with pagination hint (no re-spill) + """ + limit = max_tool_result_chars + + # Errors always pass through unchanged + if result.is_error: + return result + + # load_data reads FROM spilled files — never re-spill (circular). + # Just truncate with a pagination hint if the result is too large. + if tool_name == "load_data": + if limit <= 0 or len(result.content) <= limit: + return result # Small load_data result — pass through as-is + # Large load_data result — truncate with smart preview + PREVIEW_CAP = min(5000, max(limit - 500, limit // 2)) + + metadata_str = "" + smart_preview: str | None = None + try: + parsed_ld = json.loads(result.content) + metadata_str = extract_json_metadata(parsed_ld) + smart_preview = build_json_preview(parsed_ld, max_chars=PREVIEW_CAP) + except (json.JSONDecodeError, TypeError, ValueError): + pass + + if smart_preview is not None: + preview_block = smart_preview + else: + preview_block = result.content[:PREVIEW_CAP] + "…" + + header = ( + f"[{tool_name} result: {len(result.content):,} chars — " + f"too large for context. Use offset_bytes/limit_bytes " + f"parameters to read smaller chunks.]" + ) + if metadata_str: + header += f"\n\nData structure:\n{metadata_str}" + header += ( + "\n\nWARNING: This is an INCOMPLETE preview. " + "Do NOT draw conclusions or counts from it." + ) + + truncated = f"{header}\n\nPreview (small sample only):\n{preview_block}" + logger.info( + "%s result truncated: %d → %d chars (use offset/limit to paginate)", + tool_name, + len(result.content), + len(truncated), + ) + return ToolResult( + tool_use_id=result.tool_use_id, + content=truncated, + is_error=False, + ) + + spill_dir = spillover_dir + if spill_dir: + spill_path = Path(spill_dir) + spill_path.mkdir(parents=True, exist_ok=True) + filename = next_spill_filename_fn(tool_name) + + # Pretty-print JSON content so load_data's line-based + # pagination works correctly. + write_content = result.content + parsed_json: Any = None # track for metadata extraction + try: + parsed_json = json.loads(result.content) + write_content = json.dumps(parsed_json, indent=2, ensure_ascii=False) + except (json.JSONDecodeError, TypeError, ValueError): + pass # Not JSON — write as-is + + (spill_path / filename).write_text(write_content, encoding="utf-8") + + if limit > 0 and len(result.content) > limit: + # Large result: build a small, metadata-rich preview so the + # LLM cannot mistake it for the complete dataset. + PREVIEW_CAP = 5000 + + # Extract structural metadata (array lengths, key names) + metadata_str = "" + smart_preview: str | None = None + if parsed_json is not None: + metadata_str = extract_json_metadata(parsed_json) + smart_preview = build_json_preview(parsed_json, max_chars=PREVIEW_CAP) + + if smart_preview is not None: + preview_block = smart_preview + else: + preview_block = result.content[:PREVIEW_CAP] + "…" + + # Assemble header with structural info + warning + header = ( + f"[Result from {tool_name}: {len(result.content):,} chars — " + f"too large for context, saved to '{filename}'.]\n" + ) + if metadata_str: + header += f"\nData structure:\n{metadata_str}" + header += ( + f"\n\nWARNING: The preview below is INCOMPLETE. " + f"Do NOT draw conclusions or counts from it. " + f"Use load_data(filename='{filename}') to read the " + f"full data before analysis." + ) + + content = f"{header}\n\nPreview (small sample only):\n{preview_block}" + logger.info( + "Tool result spilled to file: %s (%d chars → %s)", + tool_name, + len(result.content), + filename, + ) + else: + # Small result: keep full content + annotation + content = f"{result.content}\n\n[Saved to '{filename}']" + logger.info( + "Tool result saved to file: %s (%d chars → %s)", + tool_name, + len(result.content), + filename, + ) + + return ToolResult( + tool_use_id=result.tool_use_id, + content=content, + is_error=False, + ) + + # No spillover_dir — truncate in-place if needed + if limit > 0 and len(result.content) > limit: + PREVIEW_CAP = min(5000, max(limit - 500, limit // 2)) + + metadata_str = "" + smart_preview: str | None = None + try: + parsed_inline = json.loads(result.content) + metadata_str = extract_json_metadata(parsed_inline) + smart_preview = build_json_preview(parsed_inline, max_chars=PREVIEW_CAP) + except (json.JSONDecodeError, TypeError, ValueError): + pass + + if smart_preview is not None: + preview_block = smart_preview + else: + preview_block = result.content[:PREVIEW_CAP] + "…" + + header = ( + f"[Result from {tool_name}: {len(result.content):,} chars — " + f"truncated to fit context budget.]" + ) + if metadata_str: + header += f"\n\nData structure:\n{metadata_str}" + header += ( + "\n\nWARNING: This is an INCOMPLETE preview. " + "Do NOT draw conclusions or counts from the preview alone." + ) + + truncated = f"{header}\n\n{preview_block}" + logger.info( + "Tool result truncated in-place: %s (%d → %d chars)", + tool_name, + len(result.content), + len(truncated), + ) + return ToolResult( + tool_use_id=result.tool_use_id, + content=truncated, + is_error=False, + ) + + return result + + +async def execute_tool( + tool_executor: Any, # Callable[[ToolUse], ToolResult | Awaitable[ToolResult]] | None + tc: ToolCallEvent, + timeout: float, +) -> ToolResult: + """Execute a tool call, handling both sync and async executors. + + Applies ``tool_call_timeout_seconds`` to prevent hung MCP servers + from blocking the event loop indefinitely. The initial executor + call is offloaded to a thread pool so that sync executors don't + freeze the event loop. + """ + if tool_executor is None: + return ToolResult( + tool_use_id=tc.tool_use_id, + content=f"No tool executor configured for '{tc.tool_name}'", + is_error=True, + ) + tool_use = ToolUse(id=tc.tool_use_id, name=tc.tool_name, input=tc.tool_input) + + async def _run() -> ToolResult: + # Offload the executor call to a thread. Sync MCP executors + # block on future.result() — running in a thread keeps the + # event loop free so asyncio.wait_for can fire the timeout. + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, tool_executor, tool_use) + # Async executors return a coroutine — await it on the loop + if asyncio.iscoroutine(result) or asyncio.isfuture(result): + result = await result + return result + + try: + if timeout > 0: + result = await asyncio.wait_for(_run(), timeout=timeout) + else: + result = await _run() + except TimeoutError: + logger.warning("Tool '%s' timed out after %.0fs", tc.tool_name, timeout) + return ToolResult( + tool_use_id=tc.tool_use_id, + content=( + f"Tool '{tc.tool_name}' timed out after {timeout:.0f}s. " + "The operation took too long and was cancelled. " + "Try a simpler request or a different approach." + ), + is_error=True, + ) + return result + + +def record_learning(key: str, value: Any, spillover_dir: str | None) -> None: + """Append a set_output value to adapt.md as a learning entry. + + Called at set_output time — the moment knowledge is produced — so that + adapt.md accumulates the agent's outputs across the session. Since + adapt.md is injected into the system prompt, these persist through + any compaction. + """ + if not spillover_dir: + return + try: + adapt_path = Path(spillover_dir) / "adapt.md" + adapt_path.parent.mkdir(parents=True, exist_ok=True) + content = adapt_path.read_text(encoding="utf-8") if adapt_path.exists() else "" + + if "## Outputs" not in content: + content += "\n\n## Outputs\n" + + # Truncate long values for memory (full value is in shared memory) + v_str = str(value) + if len(v_str) > 500: + v_str = v_str[:500] + "…" + + entry = f"- {key}: {v_str}\n" + + # Replace existing entry for same key (update, not duplicate) + lines = content.splitlines(keepends=True) + replaced = False + for i, line in enumerate(lines): + if line.startswith(f"- {key}:"): + lines[i] = entry + replaced = True + break + if replaced: + content = "".join(lines) + else: + content += entry + + adapt_path.write_text(content, encoding="utf-8") + except Exception as e: + logger.warning("Failed to record learning for key=%s: %s", key, e) + + +def next_spill_filename(tool_name: str, counter: int) -> str: + """Return a short, monotonic filename for a tool result spill.""" + # Shorten common tool name prefixes to save tokens + short = tool_name.removeprefix("tool_").removeprefix("mcp_") + return f"{short}_{counter}.txt" + + +def restore_spill_counter(spillover_dir: str | None) -> int: + """Scan spillover_dir for existing spill files and return the max counter. + + Returns the highest spill number found (or 0 if none). + """ + if not spillover_dir: + return 0 + spill_path = Path(spillover_dir) + if not spill_path.is_dir(): + return 0 + max_n = 0 + for f in spill_path.iterdir(): + if not f.is_file(): + continue + m = re.search(r"_(\d+)\.txt$", f.name) + if m: + max_n = max(max_n, int(m.group(1))) + return max_n diff --git a/core/framework/graph/event_loop_node.py b/core/framework/graph/event_loop_node.py index 9ab5ebcb86..10b1eacdf2 100644 --- a/core/framework/graph/event_loop_node.py +++ b/core/framework/graph/event_loop_node.py @@ -2862,351 +2862,45 @@ async def _timed_subagent( # ------------------------------------------------------------------- def _build_ask_user_tool(self) -> Tool: - """Build the synthetic ask_user tool for explicit user-input requests. - - Client-facing nodes call ask_user() when they need to pause and wait - for user input. Text-only turns WITHOUT ask_user flow through without - blocking, allowing progress updates and summaries to stream freely. - """ - return Tool( - name="ask_user", - description=( - "You MUST call this tool whenever you need the user's response. " - "Always call it after greeting the user, asking a question, or " - "requesting approval. Do NOT call it for status updates or " - "summaries that don't require a response. " - "Always include 2-3 predefined options. The UI automatically " - "appends an 'Other' free-text input after your options, so NEVER " - "include catch-all options like 'Custom idea', 'Something else', " - "'Other', or 'None of the above' — the UI handles that. " - "When the question primarily needs a typed answer but you must " - "include options, make one option signal that typing is expected " - "(e.g. 'I\\'ll type my response'). This helps users discover the " - "free-text input. " - "The ONLY exception: omit options when the question demands a " - "free-form answer the user must type out (e.g. 'Describe your " - "agent idea', 'Paste the error message'). " - 'Example: {"question": "What would you like to do?", "options": ' - '["Build a new agent", "Modify existing agent", "Run tests"]} ' - "Free-form example: " - '{"question": "Describe the agent you want to build."}' - ), - parameters={ - "type": "object", - "properties": { - "question": { - "type": "string", - "description": "The question or prompt shown to the user.", - }, - "options": { - "type": "array", - "items": {"type": "string"}, - "description": ( - "2-3 specific predefined choices. Include in most cases. " - 'Example: ["Option A", "Option B", "Option C"]. ' - "The UI always appends an 'Other' free-text input, so " - "do NOT include catch-alls like 'Custom idea' or 'Other'. " - "Omit ONLY when the user must type a free-form answer." - ), - "minItems": 2, - "maxItems": 3, - }, - }, - "required": ["question"], - }, - ) + """Build the synthetic ask_user tool. Delegates to synthetic_tools module.""" + from framework.graph.event_loop.synthetic_tools import build_ask_user_tool + return build_ask_user_tool() def _build_ask_user_multiple_tool(self) -> Tool: - """Build the synthetic ask_user_multiple tool for batched questions. - - Queen-only tool that presents multiple questions at once so the user - can answer them all in a single interaction rather than one at a time. - """ - return Tool( - name="ask_user_multiple", - description=( - "Ask the user multiple questions at once. Use this instead of " - "ask_user when you have 2 or more questions to ask in the same " - "turn — it lets the user answer everything in one go rather than " - "going back and forth. Each question can have its own predefined " - "options (2-3 choices) or be free-form. The UI renders all " - "questions together with a single Submit button. " - "ALWAYS prefer this over ask_user when you have multiple things " - "to clarify. " - "IMPORTANT: Do NOT repeat the questions in your text response — " - "the widget renders them. Keep your text to a brief intro only. " - 'Example: {"questions": [' - ' {"id": "scope", "prompt": "What scope?", "options": ["Full", "Partial"]},' - ' {"id": "format", "prompt": "Output format?", "options": ["PDF", "CSV", "JSON"]},' - ' {"id": "details", "prompt": "Any special requirements?"}' - "]}" - ), - parameters={ - "type": "object", - "properties": { - "questions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": ( - "Short identifier for this question (used in the response)." - ), - }, - "prompt": { - "type": "string", - "description": "The question text shown to the user.", - }, - "options": { - "type": "array", - "items": {"type": "string"}, - "description": ( - "2-3 predefined choices. The UI appends an " - "'Other' free-text input automatically. " - "Omit only when the user must type a free-form answer." - ), - "minItems": 2, - "maxItems": 3, - }, - }, - "required": ["id", "prompt"], - }, - "minItems": 2, - "maxItems": 8, - "description": "List of questions to present to the user.", - }, - }, - "required": ["questions"], - }, - ) + """Build the synthetic ask_user_multiple tool. Delegates to synthetic_tools module.""" + from framework.graph.event_loop.synthetic_tools import build_ask_user_multiple_tool + return build_ask_user_multiple_tool() def _build_set_output_tool(self, output_keys: list[str] | None) -> Tool | None: - """Build the synthetic set_output tool for explicit output declaration.""" - if not output_keys: - return None - return Tool( - name="set_output", - description=( - "Set an output value for this node. Call once per output key. " - "Use this for brief notes, counts, status, and file references — " - "NOT for large data payloads. When a tool result was saved to a " - "data file, pass the filename as the value " - "(e.g. 'google_sheets_get_values_1.txt') so the next phase can " - "load the full data. Values exceeding ~2000 characters are " - "auto-saved to data files. " - f"Valid keys: {output_keys}" - ), - parameters={ - "type": "object", - "properties": { - "key": { - "type": "string", - "description": f"Output key. Must be one of: {output_keys}", - "enum": output_keys, - }, - "value": { - "type": "string", - "description": ( - "The output value — a brief note, count, status, " - "or data filename reference." - ), - }, - }, - "required": ["key", "value"], - }, - ) + """Build the synthetic set_output tool. Delegates to synthetic_tools module.""" + from framework.graph.event_loop.synthetic_tools import build_set_output_tool + return build_set_output_tool(output_keys) def _build_escalate_tool(self) -> Tool: - """Build the synthetic escalate tool for worker -> queen handoff.""" - return Tool( - name="escalate", - description=( - "Escalate to the queen when requesting user input, " - "blocked by errors, missing " - "credentials, or ambiguous constraints that require supervisor " - "guidance. Include a concise reason and optional context. " - "The node will pause until the queen injects guidance." - ), - parameters={ - "type": "object", - "properties": { - "reason": { - "type": "string", - "description": ( - "Short reason for escalation (e.g. 'Tool repeatedly failing')." - ), - }, - "context": { - "type": "string", - "description": "Optional diagnostic details for the queen.", - }, - }, - "required": ["reason"], - }, - ) + """Build the synthetic escalate tool. Delegates to synthetic_tools module.""" + from framework.graph.event_loop.synthetic_tools import build_escalate_tool + return build_escalate_tool() def _build_delegate_tool( self, sub_agents: list[str], node_registry: dict[str, Any] ) -> Tool | None: - """Build the synthetic delegate_to_sub_agent tool for subagent invocation. - - Args: - sub_agents: List of node IDs that can be invoked as subagents. - node_registry: Map of node_id -> NodeSpec for looking up subagent descriptions. - - Returns: - Tool definition if sub_agents is non-empty, None otherwise. - """ - if not sub_agents: - return None - - agent_descriptions = [] - for agent_id in sub_agents: - spec = node_registry.get(agent_id) - if spec: - desc = getattr(spec, "description", "(no description)") - agent_descriptions.append(f"- {agent_id}: {desc}") - else: - agent_descriptions.append(f"- {agent_id}: (not found in registry)") - - return Tool( - name="delegate_to_sub_agent", - description=( - "Delegate a task to a specialized sub-agent. The sub-agent runs " - "autonomously with read-only access to current memory and returns " - "its result. Use this to parallelize work or leverage specialized capabilities.\n\n" - "Available sub-agents:\n" + "\n".join(agent_descriptions) - ), - parameters={ - "type": "object", - "properties": { - "agent_id": { - "type": "string", - "description": f"The sub-agent to invoke. Must be one of: {sub_agents}", - "enum": sub_agents, - }, - "task": { - "type": "string", - "description": ( - "The task description for the sub-agent to execute. " - "Be specific about what you want the sub-agent to do and " - "what information to return." - ), - }, - }, - "required": ["agent_id", "task"], - }, - ) + """Build the synthetic delegate_to_sub_agent tool. Delegates to synthetic_tools module.""" + from framework.graph.event_loop.synthetic_tools import build_delegate_tool + return build_delegate_tool(sub_agents, node_registry) def _build_report_to_parent_tool(self) -> Tool: - """Build the synthetic report_to_parent tool for sub-agent progress reports. - - Sub-agents call this to send one-way progress updates, partial findings, - or status reports to the parent node (and external observers via event bus) - without blocking execution. - - When ``wait_for_response`` is True, the sub-agent blocks until the parent - relays the user's response — used for escalation (e.g. login pages, CAPTCHAs). - - When ``mark_complete`` is True, the sub-agent terminates immediately after - sending the report — no need to call set_output for each output key. - """ - return Tool( - name="report_to_parent", - description=( - "Send a report to the parent agent. By default this is fire-and-forget: " - "the parent receives the report but does not respond. " - "Set wait_for_response=true to BLOCK until the user replies — use this " - "when you need human intervention (e.g. login pages, CAPTCHAs, " - "authentication walls). The user's response is returned as the tool result. " - "Set mark_complete=true to finish your task and terminate immediately " - "after sending the report — use this when your findings are in the " - "message/data fields and you don't need to call set_output." - ), - parameters={ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "A human-readable status or progress message.", - }, - "data": { - "type": "object", - "description": "Optional structured data to include with the report.", - }, - "wait_for_response": { - "type": "boolean", - "description": ( - "If true, block execution until the user responds. " - "Use for escalation scenarios requiring human intervention." - ), - "default": False, - }, - "mark_complete": { - "type": "boolean", - "description": ( - "If true, terminate the sub-agent immediately after sending " - "this report. The report message and data are delivered to the " - "parent as the final result. No set_output calls are needed." - ), - "default": False, - }, - }, - "required": ["message"], - }, - ) + """Build the synthetic report_to_parent tool. Delegates to synthetic_tools module.""" + from framework.graph.event_loop.synthetic_tools import build_report_to_parent_tool + return build_report_to_parent_tool() def _handle_set_output( self, tool_input: dict[str, Any], output_keys: list[str] | None, ) -> ToolResult: - """Handle set_output tool call. Returns ToolResult (sync).""" - key = tool_input.get("key", "") - value = tool_input.get("value", "") - valid_keys = output_keys or [] - - # Recover from truncated JSON (max_tokens hit mid-argument). - # The _raw key is set by litellm when json.loads fails. - if not key and "_raw" in tool_input: - import re - - raw = tool_input["_raw"] - key_match = re.search(r'"key"\s*:\s*"(\w+)"', raw) - if key_match: - key = key_match.group(1) - val_match = re.search(r'"value"\s*:\s*"', raw) - if val_match: - start = val_match.end() - value = raw[start:].rstrip() - for suffix in ('"}\n', '"}', '"'): - if value.endswith(suffix): - value = value[: -len(suffix)] - break - if key: - logger.warning( - "Recovered set_output args from truncated JSON: key=%s, value_len=%d", - key, - len(value), - ) - # Re-inject so the caller sees proper key/value - tool_input["key"] = key - tool_input["value"] = value - - if key not in valid_keys: - return ToolResult( - tool_use_id="", - content=f"Invalid output key '{key}'. Valid keys: {valid_keys}", - is_error=True, - ) - - return ToolResult( - tool_use_id="", - content=f"Output '{key}' set successfully.", - is_error=False, - ) + """Handle set_output tool call. Delegates to synthetic_tools module.""" + from framework.graph.event_loop.synthetic_tools import handle_set_output + return handle_set_output(tool_input, output_keys) # ------------------------------------------------------------------- # Judge evaluation @@ -3221,120 +2915,21 @@ async def _judge_turn( tool_results: list[dict], iteration: int, ) -> JudgeVerdict: - """Evaluate the current state using judge or implicit logic. - - Evaluation levels (in order): - 0. Short-circuits: mark_complete, skip_judge, tool-continue. - 1. Custom judge (JudgeProtocol) — full authority when set. - 2. Implicit judge — output-key check + optional conversation-aware - quality gate (when ``success_criteria`` is defined). - - Returns a JudgeVerdict. ``feedback=None`` means no real evaluation - happened (skip_judge, tool-continue); the caller must not inject a - feedback message. Any non-None feedback (including ``""``) means a - real evaluation occurred and will be logged into the conversation. - """ - - # --- Level 0: short-circuits (no evaluation) ----------------------- - - if self._mark_complete_flag: - return JudgeVerdict(action="ACCEPT") - - if ctx.node_spec.skip_judge: - return JudgeVerdict(action="RETRY") # feedback=None → not logged - - # --- Level 1: custom judge ----------------------------------------- - - if self._judge is not None: - context = { - "assistant_text": assistant_text, - "tool_calls": tool_results, - "output_accumulator": accumulator.to_dict(), - "accumulator": accumulator, - "iteration": iteration, - "conversation_summary": conversation.export_summary(), - "output_keys": ctx.node_spec.output_keys, - "missing_keys": self._get_missing_output_keys( - accumulator, ctx.node_spec.output_keys, ctx.node_spec.nullable_output_keys - ), - } - verdict = await self._judge.evaluate(context) - # Ensure evaluated RETRY always carries feedback for logging. - if verdict.action == "RETRY" and not verdict.feedback: - return JudgeVerdict(action="RETRY", feedback="Custom judge returned RETRY.") - return verdict - - # --- Level 2: implicit judge --------------------------------------- - - # Real tool calls were made — let the agent keep working. - if tool_results: - return JudgeVerdict(action="RETRY") # feedback=None → not logged - - missing = self._get_missing_output_keys( - accumulator, ctx.node_spec.output_keys, ctx.node_spec.nullable_output_keys + """Evaluate the current state. Delegates to judge_pipeline module.""" + from framework.graph.event_loop.judge_pipeline import judge_turn + return await judge_turn( + mark_complete_flag=self._mark_complete_flag, + judge=self._judge, + ctx=ctx, + conversation=conversation, + accumulator=accumulator, + assistant_text=assistant_text, + tool_results=tool_results, + iteration=iteration, + get_missing_output_keys_fn=self._get_missing_output_keys, + max_context_tokens=self._config.max_context_tokens, ) - if missing: - return JudgeVerdict( - action="RETRY", - feedback=( - f"Task incomplete. Required outputs not yet produced: {missing}. " - f"Follow your system prompt instructions to complete the work." - ), - ) - - # All output keys present — run safety checks before accepting. - - output_keys = ctx.node_spec.output_keys or [] - nullable_keys = set(ctx.node_spec.nullable_output_keys or []) - - # All-nullable with nothing set → node produced nothing useful. - all_nullable = output_keys and nullable_keys >= set(output_keys) - none_set = not any(accumulator.get(k) is not None for k in output_keys) - if all_nullable and none_set: - return JudgeVerdict( - action="RETRY", - feedback=( - f"No output keys have been set yet. " - f"Use set_output to set at least one of: {output_keys}" - ), - ) - - # Client-facing with no output keys → continuous interaction node. - # Inject tool-use pressure instead of auto-accepting. - if not output_keys and ctx.node_spec.client_facing: - return JudgeVerdict( - action="RETRY", - feedback=( - "STOP describing what you will do. " - "You have FULL access to all tools — file creation, " - "shell commands, MCP tools — and you CAN call them " - "directly in your response. Respond ONLY with tool " - "calls, no prose. Execute the task now." - ), - ) - - # Level 2b: conversation-aware quality check (if success_criteria set) - if ctx.node_spec.success_criteria and ctx.llm: - from framework.graph.conversation_judge import evaluate_phase_completion - - verdict = await evaluate_phase_completion( - llm=ctx.llm, - conversation=conversation, - phase_name=ctx.node_spec.name, - phase_description=ctx.node_spec.description, - success_criteria=ctx.node_spec.success_criteria, - accumulator_state=accumulator.to_dict(), - max_context_tokens=self._config.max_context_tokens, - ) - if verdict.action != "ACCEPT": - return JudgeVerdict( - action=verdict.action, - feedback=verdict.feedback or "Phase criteria not met.", - ) - - return JudgeVerdict(action="ACCEPT", feedback="") - # ------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------- @@ -3390,149 +2985,44 @@ def _get_missing_output_keys( @staticmethod def _ngram_similarity(s1: str, s2: str, n: int = 2) -> float: - """Jaccard similarity of n-gram sets. - - Returns 0.0-1.0, where 1.0 is exact match. - Fast: O(len(s) + len(s2)) using set operations. - """ - - def _ngrams(s: str) -> set[str]: - return {s[i : i + n] for i in range(len(s) - n + 1) if s.strip()} - - if not s1 or not s2: - return 0.0 - - ngrams1, ngrams2 = _ngrams(s1.lower()), _ngrams(s2.lower()) - if not ngrams1 or not ngrams2: - return 0.0 - - intersection = len(ngrams1 & ngrams2) - union = len(ngrams1 | ngrams2) - return intersection / union if union else 0.0 + """Jaccard similarity of n-gram sets. Delegates to stall_detector module.""" + from framework.graph.event_loop.stall_detector import ngram_similarity + return ngram_similarity(s1, s2, n) def _is_stalled(self, recent_responses: list[str]) -> bool: - """Detect stall using n-gram similarity. - - Detects when ALL N consecutive responses are mutually similar - (>= threshold). A single dissimilar response resets the signal. - This catches phrases like "I'm still stuck" vs "I'm stuck" - without false-positives on "attempt 1" vs "attempt 2". - """ - if len(recent_responses) < self._config.stall_detection_threshold: - return False - if not recent_responses[0]: - return False - - threshold = self._config.stall_similarity_threshold - # Every consecutive pair must be similar - for i in range(1, len(recent_responses)): - if self._ngram_similarity(recent_responses[i], recent_responses[i - 1]) < threshold: - return False - return True + """Detect stall using n-gram similarity. Delegates to stall_detector module.""" + from framework.graph.event_loop.stall_detector import is_stalled + return is_stalled( + recent_responses, + self._config.stall_detection_threshold, + self._config.stall_similarity_threshold, + ) @staticmethod def _is_transient_error(exc: BaseException) -> bool: - """Classify whether an exception is transient (retryable) vs permanent. - - Transient: network errors, rate limits, server errors, timeouts. - Permanent: auth errors, bad requests, context window exceeded. - """ - try: - from litellm.exceptions import ( - APIConnectionError, - BadGatewayError, - InternalServerError, - RateLimitError, - ServiceUnavailableError, - ) - - transient_types: tuple[type[BaseException], ...] = ( - RateLimitError, - APIConnectionError, - InternalServerError, - BadGatewayError, - ServiceUnavailableError, - TimeoutError, - ConnectionError, - OSError, - ) - except ImportError: - transient_types = (TimeoutError, ConnectionError, OSError) - - if isinstance(exc, transient_types): - return True - - # RuntimeError from StreamErrorEvent with "Stream error:" prefix - if isinstance(exc, RuntimeError): - error_str = str(exc).lower() - transient_keywords = [ - "rate limit", - "429", - "timeout", - "connection", - "internal server", - "502", - "503", - "504", - "service unavailable", - "bad gateway", - "overloaded", - "failed to parse tool call", - ] - return any(kw in error_str for kw in transient_keywords) - - return False + """Classify whether an exception is transient. Delegates to tool_result_handler module.""" + from framework.graph.event_loop.tool_result_handler import is_transient_error + return is_transient_error(exc) @staticmethod def _fingerprint_tool_calls( tool_results: list[dict], ) -> list[tuple[str, str]]: - """Create deterministic fingerprints for a turn's tool calls. - - Each fingerprint is (tool_name, canonical_args_json). Order-sensitive - so [search("a"), fetch("b")] != [fetch("b"), search("a")]. - """ - fingerprints = [] - for tr in tool_results: - name = tr.get("tool_name", "") - args = tr.get("tool_input", {}) - try: - canonical = json.dumps(args, sort_keys=True, default=str) - except (TypeError, ValueError): - canonical = str(args) - fingerprints.append((name, canonical)) - return fingerprints + """Create deterministic fingerprints. Delegates to stall_detector module.""" + from framework.graph.event_loop.stall_detector import fingerprint_tool_calls + return fingerprint_tool_calls(tool_results) def _is_tool_doom_loop( self, recent_tool_fingerprints: list[list[tuple[str, str]]], ) -> tuple[bool, str]: - """Detect doom loop via exact fingerprint match. - - Detects when N consecutive turns invoke the same tools with - identical (canonicalized) arguments. Different arguments mean - different work, so only exact matches count. - - Returns (is_doom_loop, description). - """ - if not self._config.tool_doom_loop_enabled: - return False, "" - threshold = self._config.tool_doom_loop_threshold - if len(recent_tool_fingerprints) < threshold: - return False, "" - first = recent_tool_fingerprints[0] - if not first: - return False, "" - - # All turns in the window must match the first exactly - if all(fp == first for fp in recent_tool_fingerprints[1:]): - tool_names = [name for name, _ in first] - desc = ( - f"Doom loop detected: {len(recent_tool_fingerprints)} " - f"identical consecutive tool calls ({', '.join(tool_names)})" - ) - return True, desc - return False, "" + """Detect doom loop. Delegates to stall_detector module.""" + from framework.graph.event_loop.stall_detector import is_tool_doom_loop + return is_tool_doom_loop( + recent_tool_fingerprints=recent_tool_fingerprints, + threshold=self._config.tool_doom_loop_threshold, + enabled=self._config.tool_doom_loop_enabled, + ) async def _execute_tool(self, tc: ToolCallEvent) -> ToolResult: """Execute a tool call, handling both sync and async executors. @@ -3543,43 +3033,12 @@ async def _execute_tool(self, tc: ToolCallEvent) -> ToolResult: sync executors (MCP STDIO tools that block on ``future.result()``) don't freeze the event loop. """ - if self._tool_executor is None: - return ToolResult( - tool_use_id=tc.tool_use_id, - content=f"No tool executor configured for '{tc.tool_name}'", - is_error=True, - ) - tool_use = ToolUse(id=tc.tool_use_id, name=tc.tool_name, input=tc.tool_input) - timeout = self._config.tool_call_timeout_seconds - - async def _run() -> ToolResult: - # Offload the executor call to a thread. Sync MCP executors - # block on future.result() — running in a thread keeps the - # event loop free so asyncio.wait_for can fire the timeout. - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, self._tool_executor, tool_use) - # Async executors return a coroutine — await it on the loop - if asyncio.iscoroutine(result) or asyncio.isfuture(result): - result = await result - return result - - try: - if timeout > 0: - result = await asyncio.wait_for(_run(), timeout=timeout) - else: - result = await _run() - except TimeoutError: - logger.warning("Tool '%s' timed out after %.0fs", tc.tool_name, timeout) - return ToolResult( - tool_use_id=tc.tool_use_id, - content=( - f"Tool '{tc.tool_name}' timed out after {timeout:.0f}s. " - "The operation took too long and was cancelled. " - "Try a simpler request or a different approach." - ), - is_error=True, - ) - return result + from framework.graph.event_loop.tool_result_handler import execute_tool + return await execute_tool( + tool_executor=self._tool_executor, + tc=tc, + timeout=self._config.tool_call_timeout_seconds, + ) def _record_learning(self, key: str, value: Any) -> None: """Append a set_output value to adapt.md as a learning entry. @@ -3589,39 +3048,12 @@ def _record_learning(self, key: str, value: Any) -> None: adapt.md is injected into the system prompt, these persist through any compaction. """ - if not self._config.spillover_dir: - return - try: - adapt_path = Path(self._config.spillover_dir) / "adapt.md" - adapt_path.parent.mkdir(parents=True, exist_ok=True) - content = adapt_path.read_text(encoding="utf-8") if adapt_path.exists() else "" - - if "## Outputs" not in content: - content += "\n\n## Outputs\n" - - # Truncate long values for memory (full value is in shared memory) - v_str = str(value) - if len(v_str) > 500: - v_str = v_str[:500] + "…" - - entry = f"- {key}: {v_str}\n" - - # Replace existing entry for same key (update, not duplicate) - lines = content.splitlines(keepends=True) - replaced = False - for i, line in enumerate(lines): - if line.startswith(f"- {key}:"): - lines[i] = entry - replaced = True - break - if replaced: - content = "".join(lines) - else: - content += entry - - adapt_path.write_text(content, encoding="utf-8") - except Exception as e: - logger.warning("Failed to record learning for key=%s: %s", key, e) + from framework.graph.event_loop.tool_result_handler import record_learning + return record_learning( + key=key, + value=value, + spillover_dir=self._config.spillover_dir, + ) def _next_spill_filename(self, tool_name: str) -> str: """Return a short, monotonic filename for a tool result spill.""" @@ -3632,22 +3064,10 @@ def _next_spill_filename(self, tool_name: str) -> str: def _restore_spill_counter(self) -> None: """Scan spillover_dir for existing spill files and restore the counter.""" - spill_dir = self._config.spillover_dir - if not spill_dir: - return - spill_path = Path(spill_dir) - if not spill_path.is_dir(): - return - max_n = 0 - for f in spill_path.iterdir(): - if not f.is_file(): - continue - m = re.search(r"_(\d+)\.txt$", f.name) - if m: - max_n = max(max_n, int(m.group(1))) - if max_n > self._spill_counter: - self._spill_counter = max_n - logger.info("Restored spill counter to %d from existing files", max_n) + from framework.graph.event_loop.tool_result_handler import restore_spill_counter + self._spill_counter = restore_spill_counter( + spillover_dir=self._config.spillover_dir, + ) # ------------------------------------------------------------------ # JSON metadata / smart preview helpers for truncation @@ -3662,53 +3082,10 @@ def _extract_json_metadata(parsed: Any, *, _depth: int = 0, _max_depth: int = 3) Returns an empty string for simple scalars. """ - if _depth >= _max_depth: - if isinstance(parsed, dict): - return f"dict with {len(parsed)} keys" - if isinstance(parsed, list): - return f"list of {len(parsed)} items" - return type(parsed).__name__ - - if isinstance(parsed, dict): - if not parsed: - return "empty dict" - lines: list[str] = [] - indent = " " * (_depth + 1) - for key, value in list(parsed.items())[:20]: - if isinstance(value, list): - line = f'{indent}"{key}": list of {len(value)} items' - if value: - first = value[0] - if isinstance(first, dict): - sample_keys = list(first.keys())[:10] - line += f" (each item: dict with keys {sample_keys})" - elif isinstance(first, list): - line += f" (each item: list of {len(first)} elements)" - lines.append(line) - elif isinstance(value, dict): - child = EventLoopNode._extract_json_metadata( - value, _depth=_depth + 1, _max_depth=_max_depth - ) - lines.append(f'{indent}"{key}": {child}') - else: - lines.append(f'{indent}"{key}": {type(value).__name__}') - if len(parsed) > 20: - lines.append(f"{indent}... and {len(parsed) - 20} more keys") - return "\n".join(lines) - - if isinstance(parsed, list): - if not parsed: - return "empty list" - desc = f"list of {len(parsed)} items" - first = parsed[0] - if isinstance(first, dict): - sample_keys = list(first.keys())[:10] - desc += f" (each item: dict with keys {sample_keys})" - elif isinstance(first, list): - desc += f" (each item: list of {len(first)} elements)" - return desc - - return "" + from framework.graph.event_loop.tool_result_handler import extract_json_metadata + return extract_json_metadata( + parsed=parsed, + ) @staticmethod def _build_json_preview(parsed: Any, *, max_chars: int = 5000) -> str | None: @@ -3719,54 +3096,11 @@ def _build_json_preview(parsed: Any, *, max_chars: int = 5000) -> str | None: Returns ``None`` if no truncation was needed (no large arrays). """ - _LARGE_ARRAY_THRESHOLD = 10 - - def _truncate_arrays(obj: Any) -> tuple[Any, bool]: - """Return (truncated_copy, was_truncated).""" - if isinstance(obj, list) and len(obj) > _LARGE_ARRAY_THRESHOLD: - n = len(obj) - head = obj[:3] - tail = obj[-1:] - marker = f"... ({n - 4} more items omitted, {n} total) ..." - return head + [marker] + tail, True - if isinstance(obj, dict): - changed = False - out: dict[str, Any] = {} - for k, v in obj.items(): - new_v, did = _truncate_arrays(v) - out[k] = new_v - changed = changed or did - return (out, True) if changed else (obj, False) - return obj, False - - preview_obj, was_truncated = _truncate_arrays(parsed) - if not was_truncated: - return None # No large arrays — caller should use raw slicing - - try: - result = json.dumps(preview_obj, indent=2, ensure_ascii=False) - except (TypeError, ValueError): - return None - - if len(result) > max_chars: - # Even 3+1 items too big — try just 1 item - def _minimal_arrays(obj: Any) -> Any: - if isinstance(obj, list) and len(obj) > _LARGE_ARRAY_THRESHOLD: - n = len(obj) - return obj[:1] + [f"... ({n - 1} more items omitted, {n} total) ..."] - if isinstance(obj, dict): - return {k: _minimal_arrays(v) for k, v in obj.items()} - return obj - - preview_obj = _minimal_arrays(parsed) - try: - result = json.dumps(preview_obj, indent=2, ensure_ascii=False) - except (TypeError, ValueError): - return None - if len(result) > max_chars: - result = result[:max_chars] + "…" - - return result + from framework.graph.event_loop.tool_result_handler import build_json_preview + return build_json_preview( + parsed=parsed, + max_chars=max_chars, + ) def _truncate_tool_result( self, @@ -3785,174 +3119,14 @@ def _truncate_tool_result( - Errors: pass through unchanged - load_data results: truncate with pagination hint (no re-spill) """ - limit = self._config.max_tool_result_chars - - # Errors always pass through unchanged - if result.is_error: - return result - - # load_data reads FROM spilled files — never re-spill (circular). - # Just truncate with a pagination hint if the result is too large. - if tool_name == "load_data": - if limit <= 0 or len(result.content) <= limit: - return result # Small load_data result — pass through as-is - # Large load_data result — truncate with smart preview - PREVIEW_CAP = min(5000, max(limit - 500, limit // 2)) - - metadata_str = "" - smart_preview: str | None = None - try: - parsed_ld = json.loads(result.content) - metadata_str = self._extract_json_metadata(parsed_ld) - smart_preview = self._build_json_preview(parsed_ld, max_chars=PREVIEW_CAP) - except (json.JSONDecodeError, TypeError, ValueError): - pass - - if smart_preview is not None: - preview_block = smart_preview - else: - preview_block = result.content[:PREVIEW_CAP] + "…" - - header = ( - f"[{tool_name} result: {len(result.content):,} chars — " - f"too large for context. Use offset_bytes/limit_bytes " - f"parameters to read smaller chunks.]" - ) - if metadata_str: - header += f"\n\nData structure:\n{metadata_str}" - header += ( - "\n\nWARNING: This is an INCOMPLETE preview. " - "Do NOT draw conclusions or counts from it." - ) - - truncated = f"{header}\n\nPreview (small sample only):\n{preview_block}" - logger.info( - "%s result truncated: %d → %d chars (use offset/limit to paginate)", - tool_name, - len(result.content), - len(truncated), - ) - return ToolResult( - tool_use_id=result.tool_use_id, - content=truncated, - is_error=False, - ) - - spill_dir = self._config.spillover_dir - if spill_dir: - spill_path = Path(spill_dir) - spill_path.mkdir(parents=True, exist_ok=True) - filename = self._next_spill_filename(tool_name) - - # Pretty-print JSON content so load_data's line-based - # pagination works correctly. - write_content = result.content - parsed_json: Any = None # track for metadata extraction - try: - parsed_json = json.loads(result.content) - write_content = json.dumps(parsed_json, indent=2, ensure_ascii=False) - except (json.JSONDecodeError, TypeError, ValueError): - pass # Not JSON — write as-is - - (spill_path / filename).write_text(write_content, encoding="utf-8") - - if limit > 0 and len(result.content) > limit: - # Large result: build a small, metadata-rich preview so the - # LLM cannot mistake it for the complete dataset. - PREVIEW_CAP = 5000 - - # Extract structural metadata (array lengths, key names) - metadata_str = "" - smart_preview: str | None = None - if parsed_json is not None: - metadata_str = self._extract_json_metadata(parsed_json) - smart_preview = self._build_json_preview(parsed_json, max_chars=PREVIEW_CAP) - - if smart_preview is not None: - preview_block = smart_preview - else: - preview_block = result.content[:PREVIEW_CAP] + "…" - - # Assemble header with structural info + warning - header = ( - f"[Result from {tool_name}: {len(result.content):,} chars — " - f"too large for context, saved to '{filename}'.]" - ) - if metadata_str: - header += f"\n\nData structure:\n{metadata_str}" - header += ( - f"\n\nWARNING: The preview below is INCOMPLETE. " - f"Do NOT draw conclusions or counts from it. " - f"Use load_data(filename='{filename}') to read the " - f"full data before analysis." - ) - - content = f"{header}\n\nPreview (small sample only):\n{preview_block}" - logger.info( - "Tool result spilled to file: %s (%d chars → %s)", - tool_name, - len(result.content), - filename, - ) - else: - # Small result: keep full content + annotation - content = f"{result.content}\n\n[Saved to '{filename}']" - logger.info( - "Tool result saved to file: %s (%d chars → %s)", - tool_name, - len(result.content), - filename, - ) - - return ToolResult( - tool_use_id=result.tool_use_id, - content=content, - is_error=False, - ) - - # No spillover_dir — truncate in-place if needed - if limit > 0 and len(result.content) > limit: - PREVIEW_CAP = min(5000, max(limit - 500, limit // 2)) - - metadata_str = "" - smart_preview: str | None = None - try: - parsed_inline = json.loads(result.content) - metadata_str = self._extract_json_metadata(parsed_inline) - smart_preview = self._build_json_preview(parsed_inline, max_chars=PREVIEW_CAP) - except (json.JSONDecodeError, TypeError, ValueError): - pass - - if smart_preview is not None: - preview_block = smart_preview - else: - preview_block = result.content[:PREVIEW_CAP] + "…" - - header = ( - f"[Result from {tool_name}: {len(result.content):,} chars — " - f"truncated to fit context budget.]" - ) - if metadata_str: - header += f"\n\nData structure:\n{metadata_str}" - header += ( - "\n\nWARNING: This is an INCOMPLETE preview. " - "Do NOT draw conclusions or counts from the preview alone." - ) - - truncated = f"{header}\n\n{preview_block}" - logger.info( - "Tool result truncated in-place: %s (%d → %d chars)", - tool_name, - len(result.content), - len(truncated), - ) - return ToolResult( - tool_use_id=result.tool_use_id, - content=truncated, - is_error=False, - ) - - return result + from framework.graph.event_loop.tool_result_handler import truncate_tool_result + return truncate_tool_result( + result=result, + tool_name=tool_name, + max_tool_result_chars=self._config.max_tool_result_chars, + spillover_dir=self._config.spillover_dir, + next_spill_filename_fn=self._next_spill_filename, + ) # --- Compaction ----------------------------------------------------------- @@ -3977,78 +3151,16 @@ async def _compact( does not fully resolve the budget. 4. Emergency deterministic summary only if LLM failed or unavailable. """ - ratio_before = conversation.usage_ratio() - phase_grad = getattr(ctx, "continuous_mode", False) - - # --- Step 1: Prune old tool results (free, no LLM) --- - protect = max(2000, self._config.max_context_tokens // 12) - pruned = await conversation.prune_old_tool_results( - protect_tokens=protect, - min_prune_tokens=max(1000, protect // 3), - ) - if pruned > 0: - logger.info( - "Pruned %d old tool results: %.0f%% -> %.0f%%", - pruned, - ratio_before * 100, - conversation.usage_ratio() * 100, - ) - if not conversation.needs_compaction(): - await self._log_compaction(ctx, conversation, ratio_before) - return - - # --- Step 2: Standard structure-preserving compaction (free, no LLM) --- - # Removes freeform text to spillover files; keeps tool-call pairs in context. - spill_dir = self._config.spillover_dir - if spill_dir: - await conversation.compact_preserving_structure( - spillover_dir=spill_dir, - keep_recent=4, - phase_graduated=phase_grad, - ) - if not conversation.needs_compaction(): - await self._log_compaction(ctx, conversation, ratio_before) - return - - # --- Step 3: LLM summary compaction --- - # Structural compaction alone did not hit target. Generate an LLM summary - # and place it as the first message — more reliable for token reduction - # than offloading more content to files. - if ctx.llm is not None: - logger.info( - "LLM summary compaction triggered (%.0f%% usage)", - conversation.usage_ratio() * 100, - ) - try: - summary = await self._llm_compact( - ctx, - list(conversation.messages), - accumulator, - ) - await conversation.compact( - summary, - keep_recent=2, - phase_graduated=phase_grad, - ) - except Exception as e: - logger.warning("LLM compaction failed: %s", e) - - if not conversation.needs_compaction(): - await self._log_compaction(ctx, conversation, ratio_before) - return - - # --- Step 4: Emergency deterministic summary (LLM failed/unavailable) --- - logger.warning( - "Emergency compaction (%.0f%% usage)", - conversation.usage_ratio() * 100, - ) - summary = self._build_emergency_summary(ctx, accumulator, conversation) - await conversation.compact( - summary, - keep_recent=1, - phase_graduated=phase_grad, + from framework.graph.event_loop.compaction import compact + return await compact( + ctx=ctx, + conversation=conversation, + accumulator=accumulator, + config=self._config, + event_bus=self._event_bus, + char_limit=self._LLM_COMPACT_CHAR_LIMIT, + max_depth=self._LLM_COMPACT_MAX_DEPTH, ) - await self._log_compaction(ctx, conversation, ratio_before) # --- LLM compaction with binary-search splitting ---------------------- @@ -4066,63 +3178,16 @@ async def _llm_compact( in half and each half is summarised independently. Tool history is appended once at the top-level call (``_depth == 0``). """ - from framework.graph.conversation import extract_tool_call_history - - if _depth > self._LLM_COMPACT_MAX_DEPTH: - raise RuntimeError(f"LLM compaction recursion limit ({self._LLM_COMPACT_MAX_DEPTH})") - - formatted = self._format_messages_for_summary(messages) - - # Proactive split: avoid wasting an API call on oversized input - if len(formatted) > self._LLM_COMPACT_CHAR_LIMIT and len(messages) > 1: - summary = await self._llm_compact_split( - ctx, - messages, - accumulator, - _depth, - ) - else: - prompt = self._build_llm_compaction_prompt( - ctx, - accumulator, - formatted, - ) - summary_budget = max(1024, self._config.max_context_tokens // 2) - try: - response = await ctx.llm.acomplete( - messages=[{"role": "user", "content": prompt}], - system=( - "You are a conversation compactor for an AI agent. " - "Write a detailed summary that allows the agent to " - "continue its work. Preserve user-stated rules, " - "constraints, and account/identity preferences verbatim." - ), - max_tokens=summary_budget, - ) - summary = response.content - except Exception as e: - if _is_context_too_large_error(e) and len(messages) > 1: - logger.info( - "LLM context too large (depth=%d, msgs=%d) — splitting", - _depth, - len(messages), - ) - summary = await self._llm_compact_split( - ctx, - messages, - accumulator, - _depth, - ) - else: - raise - - # Append tool history at top level only - if _depth == 0: - tool_history = extract_tool_call_history(messages) - if tool_history and "TOOLS ALREADY CALLED" not in summary: - summary += "\n\n" + tool_history - - return summary + from framework.graph.event_loop.compaction import llm_compact + return await llm_compact( + ctx=ctx, + messages=messages, + accumulator=accumulator, + _depth=_depth, + char_limit=self._LLM_COMPACT_CHAR_LIMIT, + max_depth=self._LLM_COMPACT_MAX_DEPTH, + max_context_tokens=self._config.max_context_tokens, + ) async def _llm_compact_split( self, @@ -4132,35 +3197,24 @@ async def _llm_compact_split( _depth: int, ) -> str: """Split messages in half and summarise each half independently.""" - mid = max(1, len(messages) // 2) - s1 = await self._llm_compact(ctx, messages[:mid], None, _depth + 1) - s2 = await self._llm_compact( - ctx, - messages[mid:], - accumulator, - _depth + 1, + from framework.graph.event_loop.compaction import _llm_compact_split + return await _llm_compact_split( + ctx=ctx, + messages=messages, + accumulator=accumulator, + _depth=_depth, + char_limit=self._LLM_COMPACT_CHAR_LIMIT, + max_depth=self._LLM_COMPACT_MAX_DEPTH, + max_context_tokens=self._config.max_context_tokens, ) - return s1 + "\n\n" + s2 # --- Compaction helpers ------------------------------------------------ @staticmethod def _format_messages_for_summary(messages: list) -> str: """Format messages as text for LLM summarisation.""" - lines: list[str] = [] - for m in messages: - if m.role == "tool": - content = m.content[:500] - if len(m.content) > 500: - content += "..." - lines.append(f"[tool result]: {content}") - elif m.role == "assistant" and m.tool_calls: - names = [tc.get("function", {}).get("name", "?") for tc in m.tool_calls] - text = m.content[:200] if m.content else "" - lines.append(f"[assistant (calls: {', '.join(names)})]: {text}") - else: - lines.append(f"[{m.role}]: {m.content}") - return "\n\n".join(lines) + from framework.graph.event_loop.compaction import format_messages_for_summary + return format_messages_for_summary(messages) def _build_llm_compaction_prompt( self, @@ -4169,45 +3223,12 @@ def _build_llm_compaction_prompt( formatted_messages: str, ) -> str: """Build prompt for LLM compaction targeting 50% of token budget.""" - spec = ctx.node_spec - ctx_lines = [f"NODE: {spec.name} (id={spec.id})"] - if spec.description: - ctx_lines.append(f"PURPOSE: {spec.description}") - if spec.success_criteria: - ctx_lines.append(f"SUCCESS CRITERIA: {spec.success_criteria}") - - if accumulator: - acc = accumulator.to_dict() - done = {k: v for k, v in acc.items() if v is not None} - todo = [k for k, v in acc.items() if v is None] - if done: - ctx_lines.append( - "OUTPUTS ALREADY SET:\n" - + "\n".join(f" {k}: {str(v)[:150]}" for k, v in done.items()) - ) - if todo: - ctx_lines.append(f"OUTPUTS STILL NEEDED: {', '.join(todo)}") - elif spec.output_keys: - ctx_lines.append(f"OUTPUTS STILL NEEDED: {', '.join(spec.output_keys)}") - - target_tokens = self._config.max_context_tokens // 2 - target_chars = target_tokens * 4 - node_ctx = "\n".join(ctx_lines) - - return ( - "You are compacting an AI agent's conversation history. " - "The agent is still working and needs to continue.\n\n" - f"AGENT CONTEXT:\n{node_ctx}\n\n" - f"CONVERSATION MESSAGES:\n{formatted_messages}\n\n" - "INSTRUCTIONS:\n" - f"Write a summary of approximately {target_chars} characters " - f"(~{target_tokens} tokens).\n" - "1. Preserve ALL user-stated rules, constraints, and preferences " - "verbatim.\n" - "2. Preserve key decisions made and results obtained.\n" - "3. Preserve in-progress work state so the agent can continue.\n" - "4. Be detailed enough that the agent can resume without " - "re-doing work.\n" + from framework.graph.event_loop.compaction import build_llm_compaction_prompt + return build_llm_compaction_prompt( + ctx, + accumulator, + formatted_messages, + max_context_tokens=self._config.max_context_tokens, ) async def _log_compaction( @@ -4217,50 +3238,8 @@ async def _log_compaction( ratio_before: float, ) -> None: """Log compaction result to runtime logger and event bus.""" - ratio_after = conversation.usage_ratio() - before_pct = round(ratio_before * 100) - after_pct = round(ratio_after * 100) - - # Determine label from what happened - if after_pct >= before_pct - 1: - level = "prune_only" - elif ratio_after <= 0.6: - level = "llm" - else: - level = "structural" - - logger.info( - "Compaction complete (%s): %d%% -> %d%%", - level, - before_pct, - after_pct, - ) - - if ctx.runtime_logger: - ctx.runtime_logger.log_step( - node_id=ctx.node_id, - node_type="event_loop", - step_index=-1, - llm_text=f"Context compacted ({level}): {before_pct}% \u2192 {after_pct}%", - verdict="COMPACTION", - verdict_feedback=f"level={level} before={before_pct}% after={after_pct}%", - ) - - if self._event_bus: - from framework.runtime.event_bus import AgentEvent, EventType - - await self._event_bus.publish( - AgentEvent( - type=EventType.CONTEXT_COMPACTED, - stream_id=ctx.stream_id or ctx.node_id, - node_id=ctx.node_id, - data={ - "level": level, - "usage_before": before_pct, - "usage_after": after_pct, - }, - ) - ) + from framework.graph.event_loop.compaction import log_compaction + return await log_compaction(ctx, conversation, ratio_before, self._event_bus) def _build_emergency_summary( self, @@ -4276,108 +3255,8 @@ def _build_emergency_summary( node's known state so the LLM can continue working after compaction without losing track of its task and inputs. """ - parts = [ - "EMERGENCY COMPACTION — previous conversation was too large " - "and has been replaced with this summary.\n" - ] - - # 1. Node identity - spec = ctx.node_spec - parts.append(f"NODE: {spec.name} (id={spec.id})") - if spec.description: - parts.append(f"PURPOSE: {spec.description}") - - # 2. Inputs the node received - input_lines = [] - for key in spec.input_keys: - value = ctx.input_data.get(key) or ctx.memory.read(key) - if value is not None: - # Truncate long values but keep them recognisable - v_str = str(value) - if len(v_str) > 200: - v_str = v_str[:200] + "…" - input_lines.append(f" {key}: {v_str}") - if input_lines: - parts.append("INPUTS:\n" + "\n".join(input_lines)) - - # 3. Output accumulator state (what's been set so far) - if accumulator: - acc_state = accumulator.to_dict() - set_keys = {k: v for k, v in acc_state.items() if v is not None} - missing = [k for k, v in acc_state.items() if v is None] - if set_keys: - lines = [f" {k}: {str(v)[:150]}" for k, v in set_keys.items()] - parts.append("OUTPUTS ALREADY SET:\n" + "\n".join(lines)) - if missing: - parts.append(f"OUTPUTS STILL NEEDED: {', '.join(missing)}") - elif spec.output_keys: - parts.append(f"OUTPUTS STILL NEEDED: {', '.join(spec.output_keys)}") - - # 4. Available tools reminder - if spec.tools: - parts.append(f"AVAILABLE TOOLS: {', '.join(spec.tools)}") - - # 5. Spillover files — list actual files so the LLM can load - # them immediately instead of having to call list_data_files first. - # Inline adapt.md (agent memory) directly — it contains user rules - # and identity preferences that must survive emergency compaction. - if self._config.spillover_dir: - try: - from pathlib import Path - - data_dir = Path(self._config.spillover_dir) - if data_dir.is_dir(): - # Inline adapt.md content directly - adapt_path = data_dir / "adapt.md" - if adapt_path.is_file(): - adapt_text = adapt_path.read_text(encoding="utf-8").strip() - if adapt_text: - parts.append(f"AGENT MEMORY (adapt.md):\n{adapt_text}") - - all_files = sorted( - f.name for f in data_dir.iterdir() if f.is_file() and f.name != "adapt.md" - ) - # Separate conversation history files from regular data files - conv_files = [f for f in all_files if re.match(r"conversation_\d+\.md$", f)] - data_files = [f for f in all_files if f not in conv_files] - - if conv_files: - conv_list = "\n".join( - f" - {f} (full path: {data_dir / f})" for f in conv_files - ) - parts.append( - "CONVERSATION HISTORY (freeform messages saved during compaction — " - "use load_data('') to review earlier dialogue):\n" + conv_list - ) - if data_files: - file_list = "\n".join( - f" - {f} (full path: {data_dir / f})" for f in data_files[:30] - ) - parts.append( - "DATA FILES (use load_data('') to read):\n" + file_list - ) - if not all_files: - parts.append( - "NOTE: Large tool results may have been saved to files. " - "Use list_directory to check the data directory." - ) - except Exception: - parts.append( - "NOTE: Large tool results were saved to files. " - "Use read_file(path='') to read them." - ) - - # 6. Tool call history (prevent re-calling tools) - if conversation is not None: - tool_history = self._extract_tool_call_history(conversation) - if tool_history: - parts.append(tool_history) - - parts.append( - "\nContinue working towards setting the remaining outputs. " - "Use your tools and the inputs above." - ) - return "\n\n".join(parts) + from framework.graph.event_loop.compaction import build_emergency_summary + return build_emergency_summary(ctx, accumulator, conversation, self._config) # ------------------------------------------------------------------- # Persistence: restore, cursor, injection, pause @@ -4403,50 +3282,11 @@ async def _restore( counter, and stall/doom-loop detection state — everything needed to resume exactly where execution stopped. """ - if self._conversation_store is None: - return None - - # In isolated mode, filter parts by phase_id so the node only sees - # its own messages in the shared flat conversation store. In - # continuous mode (or when _restore is called for timer-resume) - # load all parts — the full conversation threads across nodes. - _is_continuous = getattr(ctx, "continuous_mode", False) - phase_filter = None if _is_continuous else ctx.node_id - conversation = await NodeConversation.restore( - self._conversation_store, - phase_id=phase_filter, - ) - if conversation is None: - return None - - accumulator = await OutputAccumulator.restore(self._conversation_store) - accumulator.spillover_dir = self._config.spillover_dir - accumulator.max_value_chars = self._config.max_output_value_chars - - cursor = await self._conversation_store.read_cursor() - start_iteration = cursor.get("iteration", 0) + 1 if cursor else 0 - - # Restore stall/doom-loop detection state - recent_responses: list[str] = cursor.get("recent_responses", []) if cursor else [] - raw_fps = cursor.get("recent_tool_fingerprints", []) if cursor else [] - recent_tool_fingerprints: list[list[tuple[str, str]]] = [ - [tuple(pair) for pair in fps] # type: ignore[misc] - for fps in raw_fps - ] - - logger.info( - f"Restored event loop: iteration={start_iteration}, " - f"messages={conversation.message_count}, " - f"outputs={list(accumulator.values.keys())}, " - f"stall_window={len(recent_responses)}, " - f"doom_window={len(recent_tool_fingerprints)}" - ) - return EventLoopNode._RestoredState( - conversation=conversation, - accumulator=accumulator, - start_iteration=start_iteration, - recent_responses=recent_responses, - recent_tool_fingerprints=recent_tool_fingerprints, + from framework.graph.event_loop.cursor_persistence import restore + return await restore( + conversation_store=self._conversation_store, + ctx=ctx, + config=self._config, ) async def _write_cursor( @@ -4464,46 +3304,24 @@ async def _write_cursor( Persists iteration counter, accumulator outputs, and stall/doom-loop detection state so that resume picks up exactly where execution stopped. """ - if self._conversation_store: - cursor = await self._conversation_store.read_cursor() or {} - cursor.update( - { - "iteration": iteration, - "node_id": ctx.node_id, - "next_seq": conversation.next_seq, - "outputs": accumulator.to_dict(), - } - ) - # Persist stall/doom-loop detection state for reliable resume - if recent_responses is not None: - cursor["recent_responses"] = recent_responses - if recent_tool_fingerprints is not None: - # Convert list[list[tuple]] → list[list[list]] for JSON - cursor["recent_tool_fingerprints"] = [ - [list(pair) for pair in fps] for fps in recent_tool_fingerprints - ] - await self._conversation_store.write_cursor(cursor) + from framework.graph.event_loop.cursor_persistence import write_cursor + return await write_cursor( + conversation_store=self._conversation_store, + ctx=ctx, + conversation=conversation, + accumulator=accumulator, + iteration=iteration, + recent_responses=recent_responses, + recent_tool_fingerprints=recent_tool_fingerprints, + ) async def _drain_injection_queue(self, conversation: NodeConversation) -> int: """Drain all pending injected events as user messages. Returns count.""" - count = 0 - while not self._injection_queue.empty(): - try: - content, is_client_input = self._injection_queue.get_nowait() - logger.info( - "[drain] injected message (client_input=%s): %s", - is_client_input, - content[:200] if content else "(empty)", - ) - # Real user input is stored as-is; external events get a prefix - if is_client_input: - await conversation.add_user_message(content, is_client_input=True) - else: - await conversation.add_user_message(f"[External event]: {content}") - count += 1 - except asyncio.QueueEmpty: - break - return count + from framework.graph.event_loop.cursor_persistence import drain_injection_queue + return await drain_injection_queue( + queue=self._injection_queue, + conversation=conversation, + ) async def _drain_trigger_queue(self, conversation: NodeConversation) -> int: """Drain all pending trigger events as a single batched user message. @@ -4511,27 +3329,11 @@ async def _drain_trigger_queue(self, conversation: NodeConversation) -> int: Multiple triggers are merged so the LLM sees them atomically and can reason about all pending triggers before acting. """ - triggers: list[TriggerEvent] = [] - while not self._trigger_queue.empty(): - try: - triggers.append(self._trigger_queue.get_nowait()) - except asyncio.QueueEmpty: - break - - if not triggers: - return 0 - - parts: list[str] = [] - for t in triggers: - task = t.payload.get("task", "") - task_line = f"\nTask: {task}" if task else "" - payload_str = json.dumps(t.payload, default=str) - parts.append(f"[TRIGGER: {t.trigger_type}/{t.source_id}]{task_line}\n{payload_str}") - - combined = "\n\n".join(parts) - logger.info("[drain] %d trigger(s): %s", len(triggers), combined[:200]) - await conversation.add_user_message(combined) - return len(triggers) + from framework.graph.event_loop.cursor_persistence import drain_trigger_queue + return await drain_trigger_queue( + queue=self._trigger_queue, + conversation=conversation, + ) async def _check_pause( self, @@ -4545,25 +3347,12 @@ async def _check_pause( Note: This check happens BEFORE starting iteration N, after completing N-1. If paused, the node exits having completed {iteration} iterations (0 to iteration-1). """ - # Check executor-level pause event (for /pause command, Ctrl+Z) - if ctx.pause_event and ctx.pause_event.is_set(): - completed = iteration # 0-indexed: iteration=3 means 3 iterations completed (0,1,2) - logger.info(f"⏸ Pausing after {completed} iteration(s) completed (executor-level)") - return True - - # Check context-level pause flags (legacy/alternative methods) - pause_requested = ctx.input_data.get("pause_requested", False) - if not pause_requested: - try: - pause_requested = ctx.memory.read("pause_requested") or False - except (PermissionError, KeyError): - pause_requested = False - if pause_requested: - completed = iteration - logger.info(f"⏸ Pausing after {completed} iteration(s) completed (context-level)") - return True - - return False + from framework.graph.event_loop.cursor_persistence import check_pause + return await check_pause( + ctx=ctx, + conversation=conversation, + iteration=iteration, + ) # ------------------------------------------------------------------- # EventBus publishing helpers @@ -4572,13 +3361,14 @@ async def _check_pause( async def _publish_loop_started( self, stream_id: str, node_id: str, execution_id: str = "" ) -> None: - if self._event_bus: - await self._event_bus.emit_node_loop_started( - stream_id=stream_id, - node_id=node_id, - max_iterations=self._config.max_iterations, - execution_id=execution_id, - ) + from framework.graph.event_loop.event_publishing import publish_loop_started + return await publish_loop_started( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + max_iterations=self._config.max_iterations, + execution_id=execution_id, + ) async def _generate_action_plan( self, @@ -4591,41 +3381,14 @@ async def _generate_action_plan( Runs as a fire-and-forget task so it never blocks the main loop. """ - try: - system_prompt = ctx.node_spec.system_prompt or "" - # Trim to keep the prompt small - prompt_summary = system_prompt[:500] - if len(system_prompt) > 500: - prompt_summary += "..." - - tool_names = [t.name for t in ctx.available_tools] - output_keys = ctx.node_spec.output_keys or [] - - prompt = ( - f'You are about to work on a task as node "{node_id}".\n\n' - f"System prompt:\n{prompt_summary}\n\n" - f"Tools available: {tool_names}\n" - f"Required outputs: {output_keys}\n\n" - f"Write a brief action plan (2-5 bullet points) describing " - f"what you will do to complete this task. Be specific and concise.\n" - f"Return ONLY the plan text, no preamble." - ) - - response = await ctx.llm.acomplete( - messages=[{"role": "user", "content": prompt}], - max_tokens=1024, - ) - - plan = response.content.strip() - if plan and self._event_bus: - await self._event_bus.emit_node_action_plan( - stream_id=stream_id, - node_id=node_id, - plan=plan, - execution_id=execution_id, - ) - except Exception as e: - logger.warning("Action plan generation failed for node '%s': %s", node_id, e) + from framework.graph.event_loop.event_publishing import generate_action_plan + return await generate_action_plan( + event_bus=self._event_bus, + ctx=ctx, + stream_id=stream_id, + node_id=node_id, + execution_id=execution_id, + ) async def _run_hooks( self, @@ -4641,30 +3404,13 @@ async def _run_hooks( Hooks run in registration order; each sees the prompt as left by the previous hook. """ - hook_list = self._config.hooks.get(event, []) - if not hook_list: - return - for hook in hook_list: - ctx = HookContext( - event=event, - trigger=trigger, - system_prompt=conversation.system_prompt, - ) - try: - result = await hook(ctx) - except Exception: - import logging - - logging.getLogger(__name__).warning( - "Hook '%s' raised an exception", event, exc_info=True - ) - continue - if result is None: - continue - if result.system_prompt: - conversation.update_system_prompt(result.system_prompt) - if result.inject: - await conversation.add_user_message(result.inject) + from framework.graph.event_loop.event_publishing import run_hooks + return await run_hooks( + hooks_config=self._config.hooks, + event=event, + conversation=conversation, + trigger=trigger, + ) async def _publish_iteration( self, @@ -4674,14 +3420,15 @@ async def _publish_iteration( execution_id: str = "", extra_data: dict | None = None, ) -> None: - if self._event_bus: - await self._event_bus.emit_node_loop_iteration( - stream_id=stream_id, - node_id=node_id, - iteration=iteration, - execution_id=execution_id, - extra_data=extra_data, - ) + from framework.graph.event_loop.event_publishing import publish_iteration + return await publish_iteration( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + iteration=iteration, + execution_id=execution_id, + extra_data=extra_data, + ) async def _publish_llm_turn_complete( self, @@ -4695,18 +3442,19 @@ async def _publish_llm_turn_complete( execution_id: str = "", iteration: int | None = None, ) -> None: - if self._event_bus: - await self._event_bus.emit_llm_turn_complete( - stream_id=stream_id, - node_id=node_id, - stop_reason=stop_reason, - model=model, - input_tokens=input_tokens, - output_tokens=output_tokens, - cached_tokens=cached_tokens, - execution_id=execution_id, - iteration=iteration, - ) + from framework.graph.event_loop.event_publishing import publish_llm_turn_complete + return await publish_llm_turn_complete( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + stop_reason=stop_reason, + model=model, + input_tokens=input_tokens, + output_tokens=output_tokens, + cached_tokens=cached_tokens, + execution_id=execution_id, + iteration=iteration, + ) def _log_skip_judge( self, @@ -4720,39 +3468,39 @@ def _log_skip_judge( iter_start: float, ) -> None: """Log a CONTINUE step that skips judge evaluation (e.g., waiting for input).""" - if ctx.runtime_logger: - ctx.runtime_logger.log_step( - node_id=node_id, - node_type="event_loop", - step_index=iteration, - verdict="CONTINUE", - verdict_feedback=feedback, - tool_calls=tool_calls, - llm_text=llm_text, - input_tokens=turn_tokens.get("input", 0), - output_tokens=turn_tokens.get("output", 0), - latency_ms=int((time.time() - iter_start) * 1000), - ) + from framework.graph.event_loop.event_publishing import log_skip_judge + return log_skip_judge( + ctx=ctx, + node_id=node_id, + iteration=iteration, + feedback=feedback, + tool_calls=tool_calls, + llm_text=llm_text, + turn_tokens=turn_tokens, + iter_start=iter_start, + ) async def _publish_loop_completed( self, stream_id: str, node_id: str, iterations: int, execution_id: str = "" ) -> None: - if self._event_bus: - await self._event_bus.emit_node_loop_completed( - stream_id=stream_id, - node_id=node_id, - iterations=iterations, - execution_id=execution_id, - ) + from framework.graph.event_loop.event_publishing import publish_loop_completed + return await publish_loop_completed( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + iterations=iterations, + execution_id=execution_id, + ) async def _publish_stalled(self, stream_id: str, node_id: str, execution_id: str = "") -> None: - if self._event_bus: - await self._event_bus.emit_node_stalled( - stream_id=stream_id, - node_id=node_id, - reason="Consecutive similar responses detected", - execution_id=execution_id, - ) + from framework.graph.event_loop.event_publishing import publish_stalled + + return await publish_stalled( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + execution_id=execution_id, + ) async def _publish_text_delta( self, @@ -4765,26 +3513,18 @@ async def _publish_text_delta( iteration: int | None = None, inner_turn: int = 0, ) -> None: - if self._event_bus: - if ctx.node_spec.client_facing: - await self._event_bus.emit_client_output_delta( - stream_id=stream_id, - node_id=node_id, - content=content, - snapshot=snapshot, - execution_id=execution_id, - iteration=iteration, - inner_turn=inner_turn, - ) - else: - await self._event_bus.emit_llm_text_delta( - stream_id=stream_id, - node_id=node_id, - content=content, - snapshot=snapshot, - execution_id=execution_id, - inner_turn=inner_turn, - ) + from framework.graph.event_loop.event_publishing import publish_text_delta + return await publish_text_delta( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + content=content, + snapshot=snapshot, + ctx=ctx, + execution_id=execution_id, + iteration=iteration, + inner_turn=inner_turn, + ) async def _publish_tool_started( self, @@ -4795,15 +3535,16 @@ async def _publish_tool_started( tool_input: dict, execution_id: str = "", ) -> None: - if self._event_bus: - await self._event_bus.emit_tool_call_started( - stream_id=stream_id, - node_id=node_id, - tool_use_id=tool_use_id, - tool_name=tool_name, - tool_input=tool_input, - execution_id=execution_id, - ) + from framework.graph.event_loop.event_publishing import publish_tool_started + return await publish_tool_started( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + tool_use_id=tool_use_id, + tool_name=tool_name, + tool_input=tool_input, + execution_id=execution_id, + ) async def _publish_tool_completed( self, @@ -4815,16 +3556,17 @@ async def _publish_tool_completed( is_error: bool, execution_id: str = "", ) -> None: - if self._event_bus: - await self._event_bus.emit_tool_call_completed( - stream_id=stream_id, - node_id=node_id, - tool_use_id=tool_use_id, - tool_name=tool_name, - result=result, - is_error=is_error, - execution_id=execution_id, - ) + from framework.graph.event_loop.event_publishing import publish_tool_completed + return await publish_tool_completed( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + tool_use_id=tool_use_id, + tool_name=tool_name, + result=result, + is_error=is_error, + execution_id=execution_id, + ) async def _publish_judge_verdict( self, @@ -4836,16 +3578,17 @@ async def _publish_judge_verdict( iteration: int = 0, execution_id: str = "", ) -> None: - if self._event_bus: - await self._event_bus.emit_judge_verdict( - stream_id=stream_id, - node_id=node_id, - action=action, - feedback=feedback, - judge_type=judge_type, - iteration=iteration, - execution_id=execution_id, - ) + from framework.graph.event_loop.event_publishing import publish_judge_verdict + return await publish_judge_verdict( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + action=action, + feedback=feedback, + judge_type=judge_type, + iteration=iteration, + execution_id=execution_id, + ) async def _publish_output_key_set( self, @@ -4854,10 +3597,14 @@ async def _publish_output_key_set( key: str, execution_id: str = "", ) -> None: - if self._event_bus: - await self._event_bus.emit_output_key_set( - stream_id=stream_id, node_id=node_id, key=key, execution_id=execution_id - ) + from framework.graph.event_loop.event_publishing import publish_output_key_set + return await publish_output_key_set( + event_bus=self._event_bus, + stream_id=stream_id, + node_id=node_id, + key=key, + execution_id=execution_id, + ) # ------------------------------------------------------------------- # Subagent Execution @@ -4893,341 +3640,15 @@ async def _execute_subagent( - data: Subagent's output (free-form JSON) - metadata: Execution metadata (success, tokens, latency) """ - from framework.graph.node import NodeContext, SharedMemory - - # Log subagent invocation start - logger.info( - "\n" + "=" * 60 + "\n" - "🤖 SUBAGENT INVOCATION\n" - "=" * 60 + "\n" - "Parent Node: %s\n" - "Subagent ID: %s\n" - "Task: %s\n" + "=" * 60, - ctx.node_id, - agent_id, - task[:500] + "..." if len(task) > 500 else task, - ) - - # 1. Validate agent exists in registry - if agent_id not in ctx.node_registry: - return ToolResult( - tool_use_id="", - content=json.dumps( - { - "message": f"Sub-agent '{agent_id}' not found in registry", - "data": None, - "metadata": {"agent_id": agent_id, "success": False, "error": "not_found"}, - } - ), - is_error=True, - ) - - subagent_spec = ctx.node_registry[agent_id] - - # 2. Create read-only memory snapshot - # Start with everything the parent can read from shared memory. - parent_data = ctx.memory.read_all() - - # Merge in-flight outputs from the parent's accumulator. - # set_output() writes to the accumulator but shared memory is only - # updated after the parent node completes — so the subagent would - # otherwise miss any keys the parent set before delegating. - if accumulator: - for key, value in accumulator.to_dict().items(): - if key not in parent_data: - parent_data[key] = value - - subagent_memory = SharedMemory() - for key, value in parent_data.items(): - subagent_memory.write(key, value, validate=False) - - # Allow reads for parent data AND the subagent's declared input_keys - # (input_keys may reference keys that exist but weren't in read_all, - # or keys that were just written by the accumulator). - read_keys = set(parent_data.keys()) | set(subagent_spec.input_keys or []) - scoped_memory = subagent_memory.with_permissions( - read_keys=list(read_keys), - write_keys=[], # Read-only! - ) - - # 2b. Set up report callback (one-way channel to parent / event bus) - subagent_reports: list[dict] = [] - - async def _report_callback( - message: str, - data: dict | None = None, - *, - wait_for_response: bool = False, - ) -> str | None: - subagent_reports.append({"message": message, "data": data, "timestamp": time.time()}) - if self._event_bus: - await self._event_bus.emit_subagent_report( - stream_id=ctx.node_id, - node_id=f"{ctx.node_id}:subagent:{agent_id}", - subagent_id=agent_id, - message=message, - data=data, - execution_id=ctx.execution_id, - ) - - if not wait_for_response: - return None - - if not self._event_bus: - logger.warning( - "Subagent '%s' requested user response but no event_bus available", - agent_id, - ) - return None - - # Create isolated receiver and register for input routing - import uuid - - escalation_id = f"{ctx.node_id}:escalation:{uuid.uuid4().hex[:8]}" - receiver = _EscalationReceiver() - registry = ctx.shared_node_registry - - registry[escalation_id] = receiver - try: - # Escalate to the queen instead of asking the user directly. - # The queen handles the request and injects the response via - # inject_worker_message(), which finds this receiver through - # its _awaiting_input flag. - await self._event_bus.emit_escalation_requested( - stream_id=ctx.stream_id or ctx.node_id, - node_id=escalation_id, - reason=f"Subagent report (wait_for_response) from {agent_id}", - context=message, - execution_id=ctx.execution_id, - ) - # Block until queen responds - return await receiver.wait() - finally: - registry.pop(escalation_id, None) - - # 3. Filter tools for subagent - # Use the full tool catalog (ctx.all_tools) so subagents can access tools - # that aren't in the parent node's filtered set (e.g. browser tools for a - # GCU subagent when the parent only has web_scrape/save_data). - # Falls back to ctx.available_tools if all_tools is empty (e.g. in tests). - subagent_tool_names = set(subagent_spec.tools or []) - tool_source = ctx.all_tools if ctx.all_tools else ctx.available_tools - - # GCU auto-population: GCU nodes declare tools=[] because the runner - # auto-populates them at setup time. But that expansion doesn't reach - # subagents invoked via delegate_to_sub_agent — the subagent spec still - # has the original empty list. When a GCU subagent has no declared - # tools, include all catalog tools so browser tools are available. - if subagent_spec.node_type == "gcu" and not subagent_tool_names: - subagent_tools = [t for t in tool_source if t.name != "delegate_to_sub_agent"] - else: - subagent_tools = [ - t - for t in tool_source - if t.name in subagent_tool_names and t.name != "delegate_to_sub_agent" - ] - - missing = subagent_tool_names - {t.name for t in subagent_tools} - if missing: - logger.warning( - "Subagent '%s' requested tools not found in catalog: %s", - agent_id, - sorted(missing), - ) - - logger.info( - "📦 Subagent '%s' configuration:\n" - " - System prompt: %s\n" - " - Tools available (%d): %s\n" - " - Memory keys inherited: %s", - agent_id, - (subagent_spec.system_prompt[:200] + "...") - if subagent_spec.system_prompt and len(subagent_spec.system_prompt) > 200 - else subagent_spec.system_prompt, - len(subagent_tools), - [t.name for t in subagent_tools], - list(parent_data.keys()), - ) - - # 4. Build subagent context - max_iter = min(self._config.max_iterations, 10) - subagent_ctx = NodeContext( - runtime=ctx.runtime, - node_id=f"{ctx.node_id}:subagent:{agent_id}", - node_spec=subagent_spec, - memory=scoped_memory, - input_data={"task": task, **parent_data}, - llm=ctx.llm, - available_tools=subagent_tools, - goal_context=( - f"Your specific task: {task}\n\n" - f"COMPLETION REQUIREMENTS:\n" - f"When your task is done, you MUST call set_output() " - f"for each required key: {subagent_spec.output_keys}\n" - f"Alternatively, call report_to_parent(mark_complete=true) " - f"with your findings in message/data.\n" - f"You have a maximum of {max_iter} turns to complete this task." - ), - goal=ctx.goal, - max_tokens=ctx.max_tokens, - runtime_logger=ctx.runtime_logger, - is_subagent_mode=True, # Prevents nested delegation - report_callback=_report_callback, - node_registry={}, # Empty - no nested subagents - shared_node_registry=ctx.shared_node_registry, # For escalation routing - ) - - # 5. Create and execute subagent EventLoopNode - # Derive a conversation store for the subagent from the parent's store. - # Each invocation gets a unique path so that repeated delegate calls - # (e.g. one per profile) don't restore a stale completed conversation. - self._subagent_instance_counter.setdefault(agent_id, 0) - self._subagent_instance_counter[agent_id] += 1 - subagent_instance = str(self._subagent_instance_counter[agent_id]) - - subagent_conv_store = None - if self._conversation_store is not None: - from framework.storage.conversation_store import FileConversationStore - - parent_base = getattr(self._conversation_store, "_base", None) - if parent_base is not None: - # Store subagent conversations parallel to the parent node, - # not nested inside it. e.g. conversations/{node}:subagent:{agent_id}:{instance}/ - conversations_dir = parent_base.parent # e.g. conversations/ - subagent_dir_name = f"{agent_id}-{subagent_instance}" - subagent_store_path = conversations_dir / subagent_dir_name - subagent_conv_store = FileConversationStore(base_path=subagent_store_path) - - # Derive a subagent-scoped spillover dir so large tool results - # (e.g. browser_snapshot) get written to disk instead of being - # silently truncated. Each instance gets its own directory to - # avoid file collisions between concurrent subagents. - subagent_spillover = None - if self._config.spillover_dir: - subagent_spillover = str( - Path(self._config.spillover_dir) / agent_id / subagent_instance - ) - - subagent_node = EventLoopNode( - event_bus=self._event_bus, # Subagent events visible to Queen via shared bus - judge=SubagentJudge(task=task, max_iterations=max_iter), - config=LoopConfig( - max_iterations=max_iter, # Tighter budget - max_tool_calls_per_turn=self._config.max_tool_calls_per_turn, - tool_call_overflow_margin=self._config.tool_call_overflow_margin, - max_context_tokens=self._config.max_context_tokens, - stall_detection_threshold=self._config.stall_detection_threshold, - max_tool_result_chars=self._config.max_tool_result_chars, - spillover_dir=subagent_spillover, - ), + from framework.graph.event_loop.subagent_executor import execute_subagent + return await execute_subagent( + ctx=ctx, + agent_id=agent_id, + task=task, + accumulator=accumulator, + event_bus=self._event_bus, + config=self._config, tool_executor=self._tool_executor, - conversation_store=subagent_conv_store, + conversation_store=self._conversation_store, + subagent_instance_counter=self._subagent_instance_counter, ) - - # Inject a unique GCU browser profile for this subagent so that - # concurrent GCU subagents (run via asyncio.gather) each get their own - # isolated BrowserContext. asyncio.gather copies the current context - # for each coroutine, so the reset token is safe to call in finally. - _profile_token = None - try: - from gcu.browser.session import set_active_profile as _set_gcu_profile - - _profile_token = _set_gcu_profile(f"{agent_id}-{subagent_instance}") - except ImportError: - pass # GCU tools not installed; no-op - - try: - logger.info("🚀 Starting subagent '%s' execution...", agent_id) - start_time = time.time() - result = await subagent_node.execute(subagent_ctx) - latency_ms = int((time.time() - start_time) * 1000) - - separator = "-" * 60 - logger.info( - "\n%s\n" - "✅ SUBAGENT '%s' COMPLETED\n" - "%s\n" - "Success: %s\n" - "Latency: %dms\n" - "Tokens used: %s\n" - "Output keys: %s\n" - "%s", - separator, - agent_id, - separator, - result.success, - latency_ms, - result.tokens_used, - list(result.output.keys()) if result.output else [], - separator, - ) - - result_json = { - "message": ( - f"Sub-agent '{agent_id}' completed successfully" - if result.success - else f"Sub-agent '{agent_id}' failed: {result.error}" - ), - "data": result.output, - "reports": subagent_reports if subagent_reports else None, - "metadata": { - "agent_id": agent_id, - "success": result.success, - "tokens_used": result.tokens_used, - "latency_ms": latency_ms, - "report_count": len(subagent_reports), - }, - } - - return ToolResult( - tool_use_id="", - content=json.dumps(result_json, indent=2, default=str), - is_error=not result.success, - ) - - except Exception as e: - logger.exception( - "\n" + "!" * 60 + "\n❌ SUBAGENT '%s' FAILED\nError: %s\n" + "!" * 60, - agent_id, - str(e), - ) - result_json = { - "message": f"Sub-agent '{agent_id}' raised exception: {e}", - "data": None, - "metadata": { - "agent_id": agent_id, - "success": False, - "error": str(e), - }, - } - return ToolResult( - tool_use_id="", - content=json.dumps(result_json, indent=2), - is_error=True, - ) - finally: - # Restore the GCU profile context that was set before this subagent ran. - if _profile_token is not None: - from gcu.browser.session import _active_profile as _gcu_profile_var - - _gcu_profile_var.reset(_profile_token) - - # Stop the browser session for this subagent's profile so tabs are - # closed immediately rather than accumulating until server shutdown. - if self._tool_executor is not None: - _subagent_profile = f"{agent_id}-{subagent_instance}" - try: - _stop_use = ToolUse( - id="gcu-cleanup", - name="browser_stop", - input={"profile": _subagent_profile}, - ) - _stop_result = self._tool_executor(_stop_use) - if asyncio.iscoroutine(_stop_result) or asyncio.isfuture(_stop_result): - await _stop_result - except Exception as _gcu_exc: - logger.warning( - "GCU browser_stop failed for profile %r: %s", - _subagent_profile, - _gcu_exc, - ) From a0b9d6afaf28eaa4bceedfae722da72abc361810 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Thu, 19 Mar 2026 09:08:10 +0530 Subject: [PATCH 02/10] chore: refresh locks --- core/frontend/package-lock.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/frontend/package-lock.json b/core/frontend/package-lock.json index c3f264e3c7..1424796cf2 100644 --- a/core/frontend/package-lock.json +++ b/core/frontend/package-lock.json @@ -60,6 +60,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1556,6 +1557,7 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1571,6 +1573,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1783,6 +1786,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3560,6 +3564,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3611,6 +3616,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3623,6 +3629,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4183,6 +4190,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", From fa7ce53fb3e42614c5b5564985916f953c746dd3 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Thu, 19 Mar 2026 09:20:18 +0530 Subject: [PATCH 03/10] style(repo): fix ruff format violations Apply Ruff formatting to the extracted event loop modules, the EventLoopNode wrappers, and the OpenRouter key check script so the lint CI format check passes cleanly. --- core/framework/graph/event_loop/compaction.py | 5 +-- .../graph/event_loop/event_publishing.py | 4 +- .../graph/event_loop/subagent_executor.py | 4 +- .../graph/event_loop/synthetic_tools.py | 4 +- .../graph/event_loop/tool_result_handler.py | 7 +-- core/framework/graph/event_loop_node.py | 44 +++++++++++++++++++ scripts/check_llm_key.py | 13 +++--- 7 files changed, 58 insertions(+), 23 deletions(-) diff --git a/core/framework/graph/event_loop/compaction.py b/core/framework/graph/event_loop/compaction.py index 61b432c218..73bca37792 100644 --- a/core/framework/graph/event_loop/compaction.py +++ b/core/framework/graph/event_loop/compaction.py @@ -442,9 +442,7 @@ def build_emergency_summary( file_list = "\n".join( f" - {f} (full path: {data_dir / f})" for f in data_files[:30] ) - parts.append( - "DATA FILES (use load_data('') to read):\n" + file_list - ) + parts.append("DATA FILES (use load_data('') to read):\n" + file_list) if not all_files: parts.append( "NOTE: Large tool results may have been saved to files. " @@ -477,4 +475,5 @@ def _extract_tool_call_history(conversation: NodeConversation) -> str: which works on raw message lists). """ from framework.graph.conversation import extract_tool_call_history + return extract_tool_call_history(list(conversation.messages)) diff --git a/core/framework/graph/event_loop/event_publishing.py b/core/framework/graph/event_loop/event_publishing.py index eabc9283b3..6674b3ab60 100644 --- a/core/framework/graph/event_loop/event_publishing.py +++ b/core/framework/graph/event_loop/event_publishing.py @@ -320,9 +320,7 @@ async def run_hooks( try: result = await hook(ctx) except Exception: - logger.warning( - "Hook '%s' raised an exception", event, exc_info=True - ) + logger.warning("Hook '%s' raised an exception", event, exc_info=True) continue if result is None: continue diff --git a/core/framework/graph/event_loop/subagent_executor.py b/core/framework/graph/event_loop/subagent_executor.py index 104544c2bd..aafb05cd69 100644 --- a/core/framework/graph/event_loop/subagent_executor.py +++ b/core/framework/graph/event_loop/subagent_executor.py @@ -269,9 +269,7 @@ async def _report_callback( # Derive a subagent-scoped spillover dir subagent_spillover = None if config.spillover_dir: - subagent_spillover = str( - Path(config.spillover_dir) / agent_id / subagent_instance - ) + subagent_spillover = str(Path(config.spillover_dir) / agent_id / subagent_instance) # Import here to avoid circular imports at module level from framework.graph.event_loop_node import EventLoopNode, LoopConfig, SubagentJudge diff --git a/core/framework/graph/event_loop/synthetic_tools.py b/core/framework/graph/event_loop/synthetic_tools.py index 6320eeb903..003b79041d 100644 --- a/core/framework/graph/event_loop/synthetic_tools.py +++ b/core/framework/graph/event_loop/synthetic_tools.py @@ -205,9 +205,7 @@ def build_escalate_tool() -> Tool: ) -def build_delegate_tool( - sub_agents: list[str], node_registry: dict[str, Any] -) -> Tool | None: +def build_delegate_tool(sub_agents: list[str], node_registry: dict[str, Any]) -> Tool | None: """Build the synthetic delegate_to_sub_agent tool for subagent invocation. Args: diff --git a/core/framework/graph/event_loop/tool_result_handler.py b/core/framework/graph/event_loop/tool_result_handler.py index aadfc6734b..94136999ff 100644 --- a/core/framework/graph/event_loop/tool_result_handler.py +++ b/core/framework/graph/event_loop/tool_result_handler.py @@ -122,9 +122,7 @@ def extract_json_metadata(parsed: Any, *, _depth: int = 0, _max_depth: int = 3) line += f" (each item: list of {len(first)} elements)" lines.append(line) elif isinstance(value, dict): - child = extract_json_metadata( - value, _depth=_depth + 1, _max_depth=_max_depth - ) + child = extract_json_metadata(value, _depth=_depth + 1, _max_depth=_max_depth) lines.append(f'{indent}"{key}": {child}') else: lines.append(f'{indent}"{key}": {type(value).__name__}') @@ -261,8 +259,7 @@ def truncate_tool_result( if metadata_str: header += f"\n\nData structure:\n{metadata_str}" header += ( - "\n\nWARNING: This is an INCOMPLETE preview. " - "Do NOT draw conclusions or counts from it." + "\n\nWARNING: This is an INCOMPLETE preview. Do NOT draw conclusions or counts from it." ) truncated = f"{header}\n\nPreview (small sample only):\n{preview_block}" diff --git a/core/framework/graph/event_loop_node.py b/core/framework/graph/event_loop_node.py index 10b1eacdf2..1b11759b02 100644 --- a/core/framework/graph/event_loop_node.py +++ b/core/framework/graph/event_loop_node.py @@ -2864,21 +2864,25 @@ async def _timed_subagent( def _build_ask_user_tool(self) -> Tool: """Build the synthetic ask_user tool. Delegates to synthetic_tools module.""" from framework.graph.event_loop.synthetic_tools import build_ask_user_tool + return build_ask_user_tool() def _build_ask_user_multiple_tool(self) -> Tool: """Build the synthetic ask_user_multiple tool. Delegates to synthetic_tools module.""" from framework.graph.event_loop.synthetic_tools import build_ask_user_multiple_tool + return build_ask_user_multiple_tool() def _build_set_output_tool(self, output_keys: list[str] | None) -> Tool | None: """Build the synthetic set_output tool. Delegates to synthetic_tools module.""" from framework.graph.event_loop.synthetic_tools import build_set_output_tool + return build_set_output_tool(output_keys) def _build_escalate_tool(self) -> Tool: """Build the synthetic escalate tool. Delegates to synthetic_tools module.""" from framework.graph.event_loop.synthetic_tools import build_escalate_tool + return build_escalate_tool() def _build_delegate_tool( @@ -2886,11 +2890,13 @@ def _build_delegate_tool( ) -> Tool | None: """Build the synthetic delegate_to_sub_agent tool. Delegates to synthetic_tools module.""" from framework.graph.event_loop.synthetic_tools import build_delegate_tool + return build_delegate_tool(sub_agents, node_registry) def _build_report_to_parent_tool(self) -> Tool: """Build the synthetic report_to_parent tool. Delegates to synthetic_tools module.""" from framework.graph.event_loop.synthetic_tools import build_report_to_parent_tool + return build_report_to_parent_tool() def _handle_set_output( @@ -2900,6 +2906,7 @@ def _handle_set_output( ) -> ToolResult: """Handle set_output tool call. Delegates to synthetic_tools module.""" from framework.graph.event_loop.synthetic_tools import handle_set_output + return handle_set_output(tool_input, output_keys) # ------------------------------------------------------------------- @@ -2917,6 +2924,7 @@ async def _judge_turn( ) -> JudgeVerdict: """Evaluate the current state. Delegates to judge_pipeline module.""" from framework.graph.event_loop.judge_pipeline import judge_turn + return await judge_turn( mark_complete_flag=self._mark_complete_flag, judge=self._judge, @@ -2987,11 +2995,13 @@ def _get_missing_output_keys( def _ngram_similarity(s1: str, s2: str, n: int = 2) -> float: """Jaccard similarity of n-gram sets. Delegates to stall_detector module.""" from framework.graph.event_loop.stall_detector import ngram_similarity + return ngram_similarity(s1, s2, n) def _is_stalled(self, recent_responses: list[str]) -> bool: """Detect stall using n-gram similarity. Delegates to stall_detector module.""" from framework.graph.event_loop.stall_detector import is_stalled + return is_stalled( recent_responses, self._config.stall_detection_threshold, @@ -3002,6 +3012,7 @@ def _is_stalled(self, recent_responses: list[str]) -> bool: def _is_transient_error(exc: BaseException) -> bool: """Classify whether an exception is transient. Delegates to tool_result_handler module.""" from framework.graph.event_loop.tool_result_handler import is_transient_error + return is_transient_error(exc) @staticmethod @@ -3010,6 +3021,7 @@ def _fingerprint_tool_calls( ) -> list[tuple[str, str]]: """Create deterministic fingerprints. Delegates to stall_detector module.""" from framework.graph.event_loop.stall_detector import fingerprint_tool_calls + return fingerprint_tool_calls(tool_results) def _is_tool_doom_loop( @@ -3018,6 +3030,7 @@ def _is_tool_doom_loop( ) -> tuple[bool, str]: """Detect doom loop. Delegates to stall_detector module.""" from framework.graph.event_loop.stall_detector import is_tool_doom_loop + return is_tool_doom_loop( recent_tool_fingerprints=recent_tool_fingerprints, threshold=self._config.tool_doom_loop_threshold, @@ -3034,6 +3047,7 @@ async def _execute_tool(self, tc: ToolCallEvent) -> ToolResult: don't freeze the event loop. """ from framework.graph.event_loop.tool_result_handler import execute_tool + return await execute_tool( tool_executor=self._tool_executor, tc=tc, @@ -3049,6 +3063,7 @@ def _record_learning(self, key: str, value: Any) -> None: any compaction. """ from framework.graph.event_loop.tool_result_handler import record_learning + return record_learning( key=key, value=value, @@ -3065,6 +3080,7 @@ def _next_spill_filename(self, tool_name: str) -> str: def _restore_spill_counter(self) -> None: """Scan spillover_dir for existing spill files and restore the counter.""" from framework.graph.event_loop.tool_result_handler import restore_spill_counter + self._spill_counter = restore_spill_counter( spillover_dir=self._config.spillover_dir, ) @@ -3083,6 +3099,7 @@ def _extract_json_metadata(parsed: Any, *, _depth: int = 0, _max_depth: int = 3) Returns an empty string for simple scalars. """ from framework.graph.event_loop.tool_result_handler import extract_json_metadata + return extract_json_metadata( parsed=parsed, ) @@ -3097,6 +3114,7 @@ def _build_json_preview(parsed: Any, *, max_chars: int = 5000) -> str | None: Returns ``None`` if no truncation was needed (no large arrays). """ from framework.graph.event_loop.tool_result_handler import build_json_preview + return build_json_preview( parsed=parsed, max_chars=max_chars, @@ -3120,6 +3138,7 @@ def _truncate_tool_result( - load_data results: truncate with pagination hint (no re-spill) """ from framework.graph.event_loop.tool_result_handler import truncate_tool_result + return truncate_tool_result( result=result, tool_name=tool_name, @@ -3152,6 +3171,7 @@ async def _compact( 4. Emergency deterministic summary only if LLM failed or unavailable. """ from framework.graph.event_loop.compaction import compact + return await compact( ctx=ctx, conversation=conversation, @@ -3179,6 +3199,7 @@ async def _llm_compact( appended once at the top-level call (``_depth == 0``). """ from framework.graph.event_loop.compaction import llm_compact + return await llm_compact( ctx=ctx, messages=messages, @@ -3198,6 +3219,7 @@ async def _llm_compact_split( ) -> str: """Split messages in half and summarise each half independently.""" from framework.graph.event_loop.compaction import _llm_compact_split + return await _llm_compact_split( ctx=ctx, messages=messages, @@ -3214,6 +3236,7 @@ async def _llm_compact_split( def _format_messages_for_summary(messages: list) -> str: """Format messages as text for LLM summarisation.""" from framework.graph.event_loop.compaction import format_messages_for_summary + return format_messages_for_summary(messages) def _build_llm_compaction_prompt( @@ -3224,6 +3247,7 @@ def _build_llm_compaction_prompt( ) -> str: """Build prompt for LLM compaction targeting 50% of token budget.""" from framework.graph.event_loop.compaction import build_llm_compaction_prompt + return build_llm_compaction_prompt( ctx, accumulator, @@ -3239,6 +3263,7 @@ async def _log_compaction( ) -> None: """Log compaction result to runtime logger and event bus.""" from framework.graph.event_loop.compaction import log_compaction + return await log_compaction(ctx, conversation, ratio_before, self._event_bus) def _build_emergency_summary( @@ -3256,6 +3281,7 @@ def _build_emergency_summary( compaction without losing track of its task and inputs. """ from framework.graph.event_loop.compaction import build_emergency_summary + return build_emergency_summary(ctx, accumulator, conversation, self._config) # ------------------------------------------------------------------- @@ -3283,6 +3309,7 @@ async def _restore( resume exactly where execution stopped. """ from framework.graph.event_loop.cursor_persistence import restore + return await restore( conversation_store=self._conversation_store, ctx=ctx, @@ -3305,6 +3332,7 @@ async def _write_cursor( detection state so that resume picks up exactly where execution stopped. """ from framework.graph.event_loop.cursor_persistence import write_cursor + return await write_cursor( conversation_store=self._conversation_store, ctx=ctx, @@ -3318,6 +3346,7 @@ async def _write_cursor( async def _drain_injection_queue(self, conversation: NodeConversation) -> int: """Drain all pending injected events as user messages. Returns count.""" from framework.graph.event_loop.cursor_persistence import drain_injection_queue + return await drain_injection_queue( queue=self._injection_queue, conversation=conversation, @@ -3330,6 +3359,7 @@ async def _drain_trigger_queue(self, conversation: NodeConversation) -> int: reason about all pending triggers before acting. """ from framework.graph.event_loop.cursor_persistence import drain_trigger_queue + return await drain_trigger_queue( queue=self._trigger_queue, conversation=conversation, @@ -3348,6 +3378,7 @@ async def _check_pause( If paused, the node exits having completed {iteration} iterations (0 to iteration-1). """ from framework.graph.event_loop.cursor_persistence import check_pause + return await check_pause( ctx=ctx, conversation=conversation, @@ -3362,6 +3393,7 @@ async def _publish_loop_started( self, stream_id: str, node_id: str, execution_id: str = "" ) -> None: from framework.graph.event_loop.event_publishing import publish_loop_started + return await publish_loop_started( event_bus=self._event_bus, stream_id=stream_id, @@ -3382,6 +3414,7 @@ async def _generate_action_plan( Runs as a fire-and-forget task so it never blocks the main loop. """ from framework.graph.event_loop.event_publishing import generate_action_plan + return await generate_action_plan( event_bus=self._event_bus, ctx=ctx, @@ -3405,6 +3438,7 @@ async def _run_hooks( previous hook. """ from framework.graph.event_loop.event_publishing import run_hooks + return await run_hooks( hooks_config=self._config.hooks, event=event, @@ -3421,6 +3455,7 @@ async def _publish_iteration( extra_data: dict | None = None, ) -> None: from framework.graph.event_loop.event_publishing import publish_iteration + return await publish_iteration( event_bus=self._event_bus, stream_id=stream_id, @@ -3443,6 +3478,7 @@ async def _publish_llm_turn_complete( iteration: int | None = None, ) -> None: from framework.graph.event_loop.event_publishing import publish_llm_turn_complete + return await publish_llm_turn_complete( event_bus=self._event_bus, stream_id=stream_id, @@ -3469,6 +3505,7 @@ def _log_skip_judge( ) -> None: """Log a CONTINUE step that skips judge evaluation (e.g., waiting for input).""" from framework.graph.event_loop.event_publishing import log_skip_judge + return log_skip_judge( ctx=ctx, node_id=node_id, @@ -3484,6 +3521,7 @@ async def _publish_loop_completed( self, stream_id: str, node_id: str, iterations: int, execution_id: str = "" ) -> None: from framework.graph.event_loop.event_publishing import publish_loop_completed + return await publish_loop_completed( event_bus=self._event_bus, stream_id=stream_id, @@ -3514,6 +3552,7 @@ async def _publish_text_delta( inner_turn: int = 0, ) -> None: from framework.graph.event_loop.event_publishing import publish_text_delta + return await publish_text_delta( event_bus=self._event_bus, stream_id=stream_id, @@ -3536,6 +3575,7 @@ async def _publish_tool_started( execution_id: str = "", ) -> None: from framework.graph.event_loop.event_publishing import publish_tool_started + return await publish_tool_started( event_bus=self._event_bus, stream_id=stream_id, @@ -3557,6 +3597,7 @@ async def _publish_tool_completed( execution_id: str = "", ) -> None: from framework.graph.event_loop.event_publishing import publish_tool_completed + return await publish_tool_completed( event_bus=self._event_bus, stream_id=stream_id, @@ -3579,6 +3620,7 @@ async def _publish_judge_verdict( execution_id: str = "", ) -> None: from framework.graph.event_loop.event_publishing import publish_judge_verdict + return await publish_judge_verdict( event_bus=self._event_bus, stream_id=stream_id, @@ -3598,6 +3640,7 @@ async def _publish_output_key_set( execution_id: str = "", ) -> None: from framework.graph.event_loop.event_publishing import publish_output_key_set + return await publish_output_key_set( event_bus=self._event_bus, stream_id=stream_id, @@ -3641,6 +3684,7 @@ async def _execute_subagent( - metadata: Execution metadata (success, tokens, latency) """ from framework.graph.event_loop.subagent_executor import execute_subagent + return await execute_subagent( ctx=ctx, agent_id=agent_id, diff --git a/scripts/check_llm_key.py b/scripts/check_llm_key.py index cfdc66bf2a..44c24c0275 100644 --- a/scripts/check_llm_key.py +++ b/scripts/check_llm_key.py @@ -33,8 +33,8 @@ "\u2212": "-", "\u2044": "/", "\u2215": "/", - "\u29F8": "/", - "\uFF0F": "/", + "\u29f8": "/", + "\uff0f": "/", } ) @@ -66,9 +66,7 @@ def _sanitize_openrouter_model_id(value: str) -> str: """Sanitize pasted OpenRouter model IDs into a comparable slug.""" normalized = unicodedata.normalize("NFKC", value or "") normalized = "".join( - ch - for ch in normalized - if unicodedata.category(ch) not in {"Cc", "Cf"} + ch for ch in normalized if unicodedata.category(ch) not in {"Cc", "Cf"} ) normalized = normalized.translate(OPENROUTER_SEPARATOR_TRANSLATION) normalized = re.sub(r"\s+", "", normalized) @@ -183,7 +181,10 @@ def check_openrouter( return {"valid": False, "message": "Invalid OpenRouter API key"} if r.status_code == 403: return {"valid": False, "message": "OpenRouter API key lacks permissions"} - return {"valid": False, "message": f"OpenRouter API returned status {r.status_code}"} + return { + "valid": False, + "message": f"OpenRouter API returned status {r.status_code}", + } def check_openrouter_model( From 9b592557708f4b389177b636a296b74d16fd6658 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Fri, 20 Mar 2026 11:05:12 +0530 Subject: [PATCH 04/10] chore: pull latest change , refactor: modularize latest change --- core/framework/graph/event_loop/compaction.py | 98 ++++++- .../graph/event_loop/subagent_executor.py | 27 +- core/framework/graph/event_loop_node.py | 258 +----------------- 3 files changed, 113 insertions(+), 270 deletions(-) diff --git a/core/framework/graph/event_loop/compaction.py b/core/framework/graph/event_loop/compaction.py index 73bca37792..f4f0593736 100644 --- a/core/framework/graph/event_loop/compaction.py +++ b/core/framework/graph/event_loop/compaction.py @@ -10,7 +10,9 @@ from __future__ import annotations import logging +import os import re +from collections.abc import Awaitable, Callable from typing import Any from framework.graph.conversation import NodeConversation @@ -32,6 +34,13 @@ async def compact( event_bus: Any | None, # EventBus char_limit: int = LLM_COMPACT_CHAR_LIMIT, max_depth: int = LLM_COMPACT_MAX_DEPTH, + build_message_inventory_fn: Callable[[NodeConversation], list[dict[str, Any]]] | None = None, + publish_context_usage_fn: ( + Callable[[NodeContext, NodeConversation, str], Awaitable[None]] | None + ) = None, + write_debug_log_fn: ( + Callable[[NodeContext, int, int, str, list[dict[str, Any]] | None], None] | None + ) = None, ) -> None: """Run the full compaction pipeline if conversation needs compaction. @@ -41,16 +50,36 @@ async def compact( 3. LLM summary compaction (recursive split if too large) 4. Emergency deterministic summary (fallback) """ - if not conversation.needs_compaction(): - return - ratio_before = conversation.usage_ratio() - phase_grad = getattr(ctx, "phase_graduated", False) + phase_grad = getattr(ctx, "continuous_mode", False) + pre_inventory: list[dict[str, Any]] | None = None + + if ratio_before >= 1.0 and build_message_inventory_fn is not None: + pre_inventory = build_message_inventory_fn(conversation) # --- Step 1: Prune old tool results (free, fast) --- - conversation.prune_old_tool_results(keep_recent=4) + protect = max(2000, config.max_context_tokens // 12) + pruned = await conversation.prune_old_tool_results( + protect_tokens=protect, + min_prune_tokens=max(1000, protect // 3), + ) + if pruned > 0: + logger.info( + "Pruned %d old tool results: %.0f%% -> %.0f%%", + pruned, + ratio_before * 100, + conversation.usage_ratio() * 100, + ) if not conversation.needs_compaction(): - await log_compaction(ctx, conversation, ratio_before, event_bus) + await log_compaction( + ctx, + conversation, + ratio_before, + event_bus, + pre_inventory=pre_inventory, + publish_context_usage_fn=publish_context_usage_fn, + write_debug_log_fn=write_debug_log_fn, + ) return # --- Step 2: Standard structure-preserving compaction (free, no LLM) --- @@ -62,7 +91,15 @@ async def compact( phase_graduated=phase_grad, ) if not conversation.needs_compaction(): - await log_compaction(ctx, conversation, ratio_before, event_bus) + await log_compaction( + ctx, + conversation, + ratio_before, + event_bus, + pre_inventory=pre_inventory, + publish_context_usage_fn=publish_context_usage_fn, + write_debug_log_fn=write_debug_log_fn, + ) return # --- Step 3: LLM summary compaction --- @@ -89,7 +126,15 @@ async def compact( logger.warning("LLM compaction failed: %s", e) if not conversation.needs_compaction(): - await log_compaction(ctx, conversation, ratio_before, event_bus) + await log_compaction( + ctx, + conversation, + ratio_before, + event_bus, + pre_inventory=pre_inventory, + publish_context_usage_fn=publish_context_usage_fn, + write_debug_log_fn=write_debug_log_fn, + ) return # --- Step 4: Emergency deterministic summary (LLM failed/unavailable) --- @@ -103,7 +148,15 @@ async def compact( keep_recent=1, phase_graduated=phase_grad, ) - await log_compaction(ctx, conversation, ratio_before, event_bus) + await log_compaction( + ctx, + conversation, + ratio_before, + event_bus, + pre_inventory=pre_inventory, + publish_context_usage_fn=publish_context_usage_fn, + write_debug_log_fn=write_debug_log_fn, + ) # --- LLM compaction with binary-search splitting ---------------------- @@ -302,6 +355,14 @@ async def log_compaction( conversation: NodeConversation, ratio_before: float, event_bus: Any | None, + *, + pre_inventory: list[dict[str, Any]] | None = None, + publish_context_usage_fn: ( + Callable[[NodeContext, NodeConversation, str], Awaitable[None]] | None + ) = None, + write_debug_log_fn: ( + Callable[[NodeContext, int, int, str, list[dict[str, Any]] | None], None] | None + ) = None, ) -> None: """Log compaction result to runtime logger and event bus.""" ratio_after = conversation.usage_ratio() @@ -336,19 +397,28 @@ async def log_compaction( if event_bus: from framework.runtime.event_bus import AgentEvent, EventType + event_data: dict[str, Any] = { + "level": level, + "usage_before": before_pct, + "usage_after": after_pct, + } + if pre_inventory is not None: + event_data["message_inventory"] = pre_inventory await event_bus.publish( AgentEvent( type=EventType.CONTEXT_COMPACTED, stream_id=ctx.stream_id or ctx.node_id, node_id=ctx.node_id, - data={ - "level": level, - "usage_before": before_pct, - "usage_after": after_pct, - }, + data=event_data, ) ) + if publish_context_usage_fn is not None: + await publish_context_usage_fn(ctx, conversation, "post_compaction") + + if write_debug_log_fn is not None and os.environ.get("HIVE_COMPACTION_DEBUG"): + write_debug_log_fn(ctx, before_pct, after_pct, level, pre_inventory) + def build_emergency_summary( ctx: NodeContext, diff --git a/core/framework/graph/event_loop/subagent_executor.py b/core/framework/graph/event_loop/subagent_executor.py index aafb05cd69..f1acd02903 100644 --- a/core/framework/graph/event_loop/subagent_executor.py +++ b/core/framework/graph/event_loop/subagent_executor.py @@ -130,7 +130,21 @@ async def execute_subagent( write_keys=[], # Read-only! ) - # 2b. Set up report callback (one-way channel to parent / event bus) + # 2b. Compute instance counter early so the callback and child context + # share the same stable node_id for this subagent invocation. + if subagent_instance_counter is not None: + subagent_instance_counter.setdefault(agent_id, 0) + subagent_instance_counter[agent_id] += 1 + subagent_instance = str(subagent_instance_counter[agent_id]) + else: + subagent_instance = "1" + + if subagent_instance == "1": + sa_node_id = f"{ctx.node_id}:subagent:{agent_id}" + else: + sa_node_id = f"{ctx.node_id}:subagent:{agent_id}:{subagent_instance}" + + # 2c. Set up report callback (one-way channel to parent / event bus) subagent_reports: list[dict] = [] async def _report_callback( @@ -143,7 +157,7 @@ async def _report_callback( if event_bus: await event_bus.emit_subagent_report( stream_id=ctx.node_id, - node_id=f"{ctx.node_id}:subagent:{agent_id}", + node_id=sa_node_id, subagent_id=agent_id, message=message, data=data, @@ -223,7 +237,7 @@ async def _report_callback( max_iter = min(config.max_iterations, 10) subagent_ctx = NodeContext( runtime=ctx.runtime, - node_id=f"{ctx.node_id}:subagent:{agent_id}", + node_id=sa_node_id, node_spec=subagent_spec, memory=scoped_memory, input_data={"task": task, **parent_data}, @@ -248,13 +262,6 @@ async def _report_callback( ) # 5. Create and execute subagent EventLoopNode - if subagent_instance_counter is not None: - subagent_instance_counter.setdefault(agent_id, 0) - subagent_instance_counter[agent_id] += 1 - subagent_instance = str(subagent_instance_counter[agent_id]) - else: - subagent_instance = "1" - subagent_conv_store = None if conversation_store is not None: from framework.storage.conversation_store import FileConversationStore diff --git a/core/framework/graph/event_loop_node.py b/core/framework/graph/event_loop_node.py index f13437ac61..8a9640b521 100644 --- a/core/framework/graph/event_loop_node.py +++ b/core/framework/graph/event_loop_node.py @@ -3231,6 +3231,9 @@ async def _compact( event_bus=self._event_bus, char_limit=self._LLM_COMPACT_CHAR_LIMIT, max_depth=self._LLM_COMPACT_MAX_DEPTH, + build_message_inventory_fn=self._build_message_inventory, + publish_context_usage_fn=self._publish_context_usage, + write_debug_log_fn=self._write_compaction_debug_log, ) # --- LLM compaction with binary-search splitting ---------------------- @@ -3359,14 +3362,15 @@ async def _log_compaction( """Log compaction result to runtime logger and event bus.""" from framework.graph.event_loop.compaction import log_compaction - return await log_compaction(ctx, conversation, ratio_before, self._event_bus) - - # Emit post-compaction usage update - await self._publish_context_usage(ctx, conversation, "post_compaction") - - # Write detailed debug log to ~/.hive/compaction_log/ when enabled - if _os.environ.get("HIVE_COMPACTION_DEBUG"): - self._write_compaction_debug_log(ctx, before_pct, after_pct, level, pre_inventory) + return await log_compaction( + ctx, + conversation, + ratio_before, + self._event_bus, + pre_inventory=pre_inventory, + publish_context_usage_fn=self._publish_context_usage, + write_debug_log_fn=self._write_compaction_debug_log, + ) @staticmethod def _write_compaction_debug_log( @@ -3891,7 +3895,6 @@ async def _execute_subagent( """ from framework.graph.event_loop.subagent_executor import execute_subagent -<<<<<<< HEAD return await execute_subagent( ctx=ctx, agent_id=agent_id, @@ -3899,243 +3902,6 @@ async def _execute_subagent( accumulator=accumulator, event_bus=self._event_bus, config=self._config, -======= - # Log subagent invocation start - logger.info( - "\n" + "=" * 60 + "\n" - "🤖 SUBAGENT INVOCATION\n" - "=" * 60 + "\n" - "Parent Node: %s\n" - "Subagent ID: %s\n" - "Task: %s\n" + "=" * 60, - ctx.node_id, - agent_id, - task[:500] + "..." if len(task) > 500 else task, - ) - - # 1. Validate agent exists in registry - if agent_id not in ctx.node_registry: - return ToolResult( - tool_use_id="", - content=json.dumps( - { - "message": f"Sub-agent '{agent_id}' not found in registry", - "data": None, - "metadata": {"agent_id": agent_id, "success": False, "error": "not_found"}, - } - ), - is_error=True, - ) - - subagent_spec = ctx.node_registry[agent_id] - - # 2. Create read-only memory snapshot - # Start with everything the parent can read from shared memory. - parent_data = ctx.memory.read_all() - - # Merge in-flight outputs from the parent's accumulator. - # set_output() writes to the accumulator but shared memory is only - # updated after the parent node completes — so the subagent would - # otherwise miss any keys the parent set before delegating. - if accumulator: - for key, value in accumulator.to_dict().items(): - if key not in parent_data: - parent_data[key] = value - - subagent_memory = SharedMemory() - for key, value in parent_data.items(): - subagent_memory.write(key, value, validate=False) - - # Allow reads for parent data AND the subagent's declared input_keys - # (input_keys may reference keys that exist but weren't in read_all, - # or keys that were just written by the accumulator). - read_keys = set(parent_data.keys()) | set(subagent_spec.input_keys or []) - scoped_memory = subagent_memory.with_permissions( - read_keys=list(read_keys), - write_keys=[], # Read-only! - ) - - # 2b. Compute instance counter early so node_id is available for the - # report callback and the NodeContext. Each delegation to the same - # agent_id gets a unique suffix (instance 1 has no suffix for backward - # compat; instance 2+ appends ":N"). - self._subagent_instance_counter.setdefault(agent_id, 0) - self._subagent_instance_counter[agent_id] += 1 - _sa_instance = self._subagent_instance_counter[agent_id] - if _sa_instance > 1: - sa_node_id = f"{ctx.node_id}:subagent:{agent_id}:{_sa_instance}" - else: - sa_node_id = f"{ctx.node_id}:subagent:{agent_id}" - subagent_instance = str(_sa_instance) - - # 2c. Set up report callback (one-way channel to parent / event bus) - subagent_reports: list[dict] = [] - - async def _report_callback( - message: str, - data: dict | None = None, - *, - wait_for_response: bool = False, - ) -> str | None: - subagent_reports.append({"message": message, "data": data, "timestamp": time.time()}) - if self._event_bus: - await self._event_bus.emit_subagent_report( - stream_id=ctx.node_id, - node_id=sa_node_id, - subagent_id=agent_id, - message=message, - data=data, - execution_id=ctx.execution_id, - ) - - if not wait_for_response: - return None - - if not self._event_bus: - logger.warning( - "Subagent '%s' requested user response but no event_bus available", - agent_id, - ) - return None - - # Create isolated receiver and register for input routing - import uuid - - escalation_id = f"{ctx.node_id}:escalation:{uuid.uuid4().hex[:8]}" - receiver = _EscalationReceiver() - registry = ctx.shared_node_registry - - registry[escalation_id] = receiver - try: - # Escalate to the queen instead of asking the user directly. - # The queen handles the request and injects the response via - # inject_worker_message(), which finds this receiver through - # its _awaiting_input flag. - await self._event_bus.emit_escalation_requested( - stream_id=ctx.stream_id or ctx.node_id, - node_id=escalation_id, - reason=f"Subagent report (wait_for_response) from {agent_id}", - context=message, - execution_id=ctx.execution_id, - ) - # Block until queen responds - return await receiver.wait() - finally: - registry.pop(escalation_id, None) - - # 3. Filter tools for subagent - # Use the full tool catalog (ctx.all_tools) so subagents can access tools - # that aren't in the parent node's filtered set (e.g. browser tools for a - # GCU subagent when the parent only has web_scrape/save_data). - # Falls back to ctx.available_tools if all_tools is empty (e.g. in tests). - subagent_tool_names = set(subagent_spec.tools or []) - tool_source = ctx.all_tools if ctx.all_tools else ctx.available_tools - - # GCU auto-population: GCU nodes declare tools=[] because the runner - # auto-populates them at setup time. But that expansion doesn't reach - # subagents invoked via delegate_to_sub_agent — the subagent spec still - # has the original empty list. When a GCU subagent has no declared - # tools, include all catalog tools so browser tools are available. - if subagent_spec.node_type == "gcu" and not subagent_tool_names: - subagent_tools = [t for t in tool_source if t.name != "delegate_to_sub_agent"] - else: - subagent_tools = [ - t - for t in tool_source - if t.name in subagent_tool_names and t.name != "delegate_to_sub_agent" - ] - - missing = subagent_tool_names - {t.name for t in subagent_tools} - if missing: - logger.warning( - "Subagent '%s' requested tools not found in catalog: %s", - agent_id, - sorted(missing), - ) - - logger.info( - "📦 Subagent '%s' configuration:\n" - " - System prompt: %s\n" - " - Tools available (%d): %s\n" - " - Memory keys inherited: %s", - agent_id, - (subagent_spec.system_prompt[:200] + "...") - if subagent_spec.system_prompt and len(subagent_spec.system_prompt) > 200 - else subagent_spec.system_prompt, - len(subagent_tools), - [t.name for t in subagent_tools], - list(parent_data.keys()), - ) - - # 4. Build subagent context - max_iter = min(self._config.max_iterations, 10) - subagent_ctx = NodeContext( - runtime=ctx.runtime, - node_id=sa_node_id, - node_spec=subagent_spec, - memory=scoped_memory, - input_data={"task": task, **parent_data}, - llm=ctx.llm, - available_tools=subagent_tools, - goal_context=( - f"Your specific task: {task}\n\n" - f"COMPLETION REQUIREMENTS:\n" - f"When your task is done, you MUST call set_output() " - f"for each required key: {subagent_spec.output_keys}\n" - f"Alternatively, call report_to_parent(mark_complete=true) " - f"with your findings in message/data.\n" - f"You have a maximum of {max_iter} turns to complete this task." - ), - goal=ctx.goal, - max_tokens=ctx.max_tokens, - runtime_logger=ctx.runtime_logger, - is_subagent_mode=True, # Prevents nested delegation - report_callback=_report_callback, - node_registry={}, # Empty - no nested subagents - shared_node_registry=ctx.shared_node_registry, # For escalation routing - ) - - # 5. Create and execute subagent EventLoopNode - # Derive a conversation store for the subagent from the parent's store. - # Each invocation gets a unique path so that repeated delegate calls - # (e.g. one per profile) don't restore a stale completed conversation. - # (Instance counter was computed earlier in step 2b.) - subagent_conv_store = None - if self._conversation_store is not None: - from framework.storage.conversation_store import FileConversationStore - - parent_base = getattr(self._conversation_store, "_base", None) - if parent_base is not None: - # Store subagent conversations parallel to the parent node, - # not nested inside it. e.g. conversations/{node}:subagent:{agent_id}:{instance}/ - conversations_dir = parent_base.parent # e.g. conversations/ - subagent_dir_name = f"{agent_id}-{subagent_instance}" - subagent_store_path = conversations_dir / subagent_dir_name - subagent_conv_store = FileConversationStore(base_path=subagent_store_path) - - # Derive a subagent-scoped spillover dir so large tool results - # (e.g. browser_snapshot) get written to disk instead of being - # silently truncated. Each instance gets its own directory to - # avoid file collisions between concurrent subagents. - subagent_spillover = None - if self._config.spillover_dir: - subagent_spillover = str( - Path(self._config.spillover_dir) / agent_id / subagent_instance - ) - - subagent_node = EventLoopNode( - event_bus=self._event_bus, # Subagent events visible to Queen via shared bus - judge=SubagentJudge(task=task, max_iterations=max_iter), - config=LoopConfig( - max_iterations=max_iter, # Tighter budget - max_tool_calls_per_turn=self._config.max_tool_calls_per_turn, - tool_call_overflow_margin=self._config.tool_call_overflow_margin, - max_context_tokens=self._config.max_context_tokens, - stall_detection_threshold=self._config.stall_detection_threshold, - max_tool_result_chars=self._config.max_tool_result_chars, - spillover_dir=subagent_spillover, - ), ->>>>>>> 764012c5985b8e6590606bab3ae4ecd99d4000c2 tool_executor=self._tool_executor, conversation_store=self._conversation_store, subagent_instance_counter=self._subagent_instance_counter, From 4724320946183b6f3519d7bf8b1b811f1fd6609e Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Tue, 24 Mar 2026 00:59:35 +0530 Subject: [PATCH 05/10] refactor(event-loop): add shared event loop types --- core/framework/graph/event_loop/types.py | 190 +++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 core/framework/graph/event_loop/types.py diff --git a/core/framework/graph/event_loop/types.py b/core/framework/graph/event_loop/types.py new file mode 100644 index 0000000000..9cbbbab462 --- /dev/null +++ b/core/framework/graph/event_loop/types.py @@ -0,0 +1,190 @@ +"""Shared types and state containers for the event loop package.""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Protocol, runtime_checkable + +from framework.graph.conversation import ConversationStore + +logger = logging.getLogger(__name__) + + +@dataclass +class TriggerEvent: + """A framework-level trigger signal (timer tick or webhook hit).""" + + trigger_type: str + source_id: str + payload: dict[str, Any] = field(default_factory=dict) + timestamp: float = field(default_factory=time.time) + + +@dataclass +class JudgeVerdict: + """Result of judge evaluation for the event loop.""" + + action: Literal["ACCEPT", "RETRY", "ESCALATE"] + # None = no evaluation happened (skip_judge, tool-continue); not logged. + # "" = evaluated but no feedback; logged with default text. + # "..." = evaluated with feedback; logged as-is. + feedback: str | None = None + + +@runtime_checkable +class JudgeProtocol(Protocol): + """Protocol for event-loop judges.""" + + async def evaluate(self, context: dict[str, Any]) -> JudgeVerdict: ... + + +@dataclass +class LoopConfig: + """Configuration for the event loop.""" + + max_iterations: int = 50 + max_tool_calls_per_turn: int = 30 + judge_every_n_turns: int = 1 + stall_detection_threshold: int = 3 + stall_similarity_threshold: float = 0.85 + max_context_tokens: int = 32_000 + store_prefix: str = "" + + # Overflow margin for max_tool_calls_per_turn. Tool calls are only + # discarded when the count exceeds max_tool_calls_per_turn * (1 + margin). + tool_call_overflow_margin: float = 0.5 + + # Tool result context management. + max_tool_result_chars: int = 30_000 + spillover_dir: str | None = None + + # set_output value spilling. + max_output_value_chars: int = 2_000 + + # Stream retry. + max_stream_retries: int = 3 + stream_retry_backoff_base: float = 2.0 + stream_retry_max_delay: float = 60.0 + + # Tool doom loop detection. + tool_doom_loop_threshold: int = 3 + + # Client-facing auto-block grace period. + cf_grace_turns: int = 1 + tool_doom_loop_enabled: bool = True + + # Per-tool-call timeout. + tool_call_timeout_seconds: float = 60.0 + + # Subagent delegation timeout. + subagent_timeout_seconds: float = 600.0 + + # Lifecycle hooks. + hooks: dict[str, list] | None = None + + def __post_init__(self) -> None: + if self.hooks is None: + object.__setattr__(self, "hooks", {}) + + +@dataclass +class HookContext: + """Context passed to every lifecycle hook.""" + + event: str + trigger: str | None + system_prompt: str + + +@dataclass +class HookResult: + """What a hook may return to modify node state.""" + + system_prompt: str | None = None + inject: str | None = None + + +@dataclass +class OutputAccumulator: + """Accumulates output key-value pairs with optional write-through persistence.""" + + values: dict[str, Any] = field(default_factory=dict) + store: ConversationStore | None = None + spillover_dir: str | None = None + max_value_chars: int = 0 + + async def set(self, key: str, value: Any) -> None: + """Set a key-value pair, auto-spilling large values to files.""" + value = self._auto_spill(key, value) + self.values[key] = value + if self.store: + cursor = await self.store.read_cursor() or {} + outputs = cursor.get("outputs", {}) + outputs[key] = value + cursor["outputs"] = outputs + await self.store.write_cursor(cursor) + + def _auto_spill(self, key: str, value: Any) -> Any: + """Save large values to a file and return a reference string.""" + if self.max_value_chars <= 0 or not self.spillover_dir: + return value + + val_str = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value + if len(val_str) <= self.max_value_chars: + return value + + spill_path = Path(self.spillover_dir) + spill_path.mkdir(parents=True, exist_ok=True) + ext = ".json" if isinstance(value, (dict, list)) else ".txt" + filename = f"output_{key}{ext}" + write_content = ( + json.dumps(value, indent=2, ensure_ascii=False) + if isinstance(value, (dict, list)) + else str(value) + ) + (spill_path / filename).write_text(write_content, encoding="utf-8") + file_size = (spill_path / filename).stat().st_size + logger.info( + "set_output value auto-spilled: key=%s, %d chars -> %s (%d bytes)", + key, + len(val_str), + filename, + file_size, + ) + return ( + f"[Saved to '{filename}' ({file_size:,} bytes). " + f"Use load_data(filename='{filename}') " + f"to access full data.]" + ) + + def get(self, key: str) -> Any | None: + return self.values.get(key) + + def to_dict(self) -> dict[str, Any]: + return dict(self.values) + + def has_all_keys(self, required: list[str]) -> bool: + return all(key in self.values and self.values[key] is not None for key in required) + + @classmethod + async def restore(cls, store: ConversationStore) -> OutputAccumulator: + cursor = await store.read_cursor() + values = {} + if cursor and "outputs" in cursor: + values = cursor["outputs"] + return cls(values=values, store=store) + + +__all__ = [ + "HookContext", + "HookResult", + "JudgeProtocol", + "JudgeVerdict", + "LoopConfig", + "OutputAccumulator", + "TriggerEvent", +] From b43044cf4d767520a5f42dd5e42cfa0df9c038fb Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Tue, 24 Mar 2026 00:59:55 +0530 Subject: [PATCH 06/10] refactor(event-loop): untangle modular event loop imports --- core/framework/graph/event_loop/compaction.py | 177 ++++++++++++++---- .../graph/event_loop/cursor_persistence.py | 19 +- .../graph/event_loop/event_publishing.py | 40 +++- .../graph/event_loop/judge_pipeline.py | 57 ++++-- .../graph/event_loop/subagent_executor.py | 44 +++-- 5 files changed, 253 insertions(+), 84 deletions(-) diff --git a/core/framework/graph/event_loop/compaction.py b/core/framework/graph/event_loop/compaction.py index f4f0593736..e8cfecaf43 100644 --- a/core/framework/graph/event_loop/compaction.py +++ b/core/framework/graph/event_loop/compaction.py @@ -9,14 +9,19 @@ from __future__ import annotations +import json import logging import os import re -from collections.abc import Awaitable, Callable +from datetime import UTC, datetime +from pathlib import Path from typing import Any from framework.graph.conversation import NodeConversation +from framework.graph.event_loop.event_publishing import publish_context_usage +from framework.graph.event_loop.types import LoopConfig, OutputAccumulator from framework.graph.node import NodeContext +from framework.runtime.event_bus import EventBus logger = logging.getLogger(__name__) @@ -28,19 +33,12 @@ async def compact( ctx: NodeContext, conversation: NodeConversation, - accumulator: Any | None, # OutputAccumulator + accumulator: OutputAccumulator | None, *, - config: Any, # LoopConfig - event_bus: Any | None, # EventBus + config: LoopConfig, + event_bus: EventBus | None, char_limit: int = LLM_COMPACT_CHAR_LIMIT, max_depth: int = LLM_COMPACT_MAX_DEPTH, - build_message_inventory_fn: Callable[[NodeConversation], list[dict[str, Any]]] | None = None, - publish_context_usage_fn: ( - Callable[[NodeContext, NodeConversation, str], Awaitable[None]] | None - ) = None, - write_debug_log_fn: ( - Callable[[NodeContext, int, int, str, list[dict[str, Any]] | None], None] | None - ) = None, ) -> None: """Run the full compaction pipeline if conversation needs compaction. @@ -54,8 +52,8 @@ async def compact( phase_grad = getattr(ctx, "continuous_mode", False) pre_inventory: list[dict[str, Any]] | None = None - if ratio_before >= 1.0 and build_message_inventory_fn is not None: - pre_inventory = build_message_inventory_fn(conversation) + if ratio_before >= 1.0: + pre_inventory = build_message_inventory(conversation) # --- Step 1: Prune old tool results (free, fast) --- protect = max(2000, config.max_context_tokens // 12) @@ -77,8 +75,6 @@ async def compact( ratio_before, event_bus, pre_inventory=pre_inventory, - publish_context_usage_fn=publish_context_usage_fn, - write_debug_log_fn=write_debug_log_fn, ) return @@ -97,8 +93,6 @@ async def compact( ratio_before, event_bus, pre_inventory=pre_inventory, - publish_context_usage_fn=publish_context_usage_fn, - write_debug_log_fn=write_debug_log_fn, ) return @@ -132,8 +126,6 @@ async def compact( ratio_before, event_bus, pre_inventory=pre_inventory, - publish_context_usage_fn=publish_context_usage_fn, - write_debug_log_fn=write_debug_log_fn, ) return @@ -154,8 +146,6 @@ async def compact( ratio_before, event_bus, pre_inventory=pre_inventory, - publish_context_usage_fn=publish_context_usage_fn, - write_debug_log_fn=write_debug_log_fn, ) @@ -165,7 +155,7 @@ async def compact( async def llm_compact( ctx: NodeContext, messages: list, - accumulator: Any | None = None, + accumulator: OutputAccumulator | None = None, _depth: int = 0, *, char_limit: int = LLM_COMPACT_CHAR_LIMIT, @@ -249,7 +239,7 @@ async def llm_compact( async def _llm_compact_split( ctx: NodeContext, messages: list, - accumulator: Any | None, + accumulator: OutputAccumulator | None, _depth: int, *, char_limit: int = LLM_COMPACT_CHAR_LIMIT, @@ -302,7 +292,7 @@ def format_messages_for_summary(messages: list) -> str: def build_llm_compaction_prompt( ctx: NodeContext, - accumulator: Any | None, # OutputAccumulator + accumulator: OutputAccumulator | None, formatted_messages: str, *, max_context_tokens: int = 128_000, @@ -350,19 +340,133 @@ def build_llm_compaction_prompt( ) +def build_message_inventory(conversation: NodeConversation) -> list[dict[str, Any]]: + """Build a per-message size inventory for debug logging.""" + inventory: list[dict[str, Any]] = [] + for message in conversation.messages: + content_chars = len(message.content) + tool_call_args_chars = 0 + tool_name = None + if message.tool_calls: + for tool_call in message.tool_calls: + args = tool_call.get("function", {}).get("arguments", "") + tool_call_args_chars += ( + len(args) if isinstance(args, str) else len(json.dumps(args)) + ) + names = [ + tool_call.get("function", {}).get("name", "?") for tool_call in message.tool_calls + ] + tool_name = ", ".join(names) + elif message.role == "tool" and message.tool_use_id: + for previous in conversation.messages: + if previous.tool_calls: + for tool_call in previous.tool_calls: + if tool_call.get("id") == message.tool_use_id: + tool_name = tool_call.get("function", {}).get("name", "?") + break + if tool_name: + break + entry: dict[str, Any] = { + "seq": message.seq, + "role": message.role, + "content_chars": content_chars, + } + if tool_call_args_chars: + entry["tool_call_args_chars"] = tool_call_args_chars + if tool_name: + entry["tool"] = tool_name + if message.is_error: + entry["is_error"] = True + if message.phase_id: + entry["phase"] = message.phase_id + if content_chars > 2000: + entry["preview"] = message.content[:200] + "…" + inventory.append(entry) + return inventory + + +def write_compaction_debug_log( + ctx: NodeContext, + before_pct: int, + after_pct: int, + level: str, + inventory: list[dict[str, Any]] | None, +) -> None: + """Write detailed compaction analysis to ~/.hive/compaction_log/.""" + log_dir = Path.home() / ".hive" / "compaction_log" + log_dir.mkdir(parents=True, exist_ok=True) + + ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%S_%f") + node_label = ctx.node_id.replace("/", "_") + log_path = log_dir / f"{ts}_{node_label}.md" + + lines: list[str] = [ + f"# Compaction Debug — {ctx.node_id}", + f"**Time:** {datetime.now(UTC).isoformat()}", + f"**Node:** {ctx.node_spec.name} (`{ctx.node_id}`)", + ] + if ctx.stream_id: + lines.append(f"**Stream:** {ctx.stream_id}") + lines.append(f"**Level:** {level}") + lines.append(f"**Usage:** {before_pct}% → {after_pct}%") + lines.append("") + + if inventory: + total_chars = sum( + entry.get("content_chars", 0) + entry.get("tool_call_args_chars", 0) + for entry in inventory + ) + lines.append( + "## Pre-Compaction Message Inventory " + f"({len(inventory)} messages, {total_chars:,} total chars)" + ) + lines.append("") + ranked = sorted( + inventory, + key=lambda entry: entry.get("content_chars", 0) + entry.get("tool_call_args_chars", 0), + reverse=True, + ) + lines.append("| # | seq | role | tool | chars | % of total | flags |") + lines.append("|---|-----|------|------|------:|------------|-------|") + for i, entry in enumerate(ranked, 1): + chars = entry.get("content_chars", 0) + entry.get("tool_call_args_chars", 0) + pct = (chars / total_chars * 100) if total_chars else 0 + tool = entry.get("tool", "") + flags: list[str] = [] + if entry.get("is_error"): + flags.append("error") + if entry.get("phase"): + flags.append(f"phase={entry['phase']}") + lines.append( + f"| {i} | {entry['seq']} | {entry['role']} | {tool} " + f"| {chars:,} | {pct:.1f}% | {', '.join(flags)} |" + ) + + large = [entry for entry in ranked if entry.get("preview")] + if large: + lines.append("") + lines.append("### Large message previews") + for entry in large: + lines.append( + f"\n**seq={entry['seq']}** ({entry['role']}, {entry.get('tool', '')}):" + ) + lines.append(f"```\n{entry['preview']}\n```") + lines.append("") + + try: + log_path.write_text("\n".join(lines), encoding="utf-8") + logger.debug("Compaction debug log written to %s", log_path) + except OSError: + logger.debug("Failed to write compaction debug log to %s", log_path) + + async def log_compaction( ctx: NodeContext, conversation: NodeConversation, ratio_before: float, - event_bus: Any | None, + event_bus: EventBus | None, *, pre_inventory: list[dict[str, Any]] | None = None, - publish_context_usage_fn: ( - Callable[[NodeContext, NodeConversation, str], Awaitable[None]] | None - ) = None, - write_debug_log_fn: ( - Callable[[NodeContext, int, int, str, list[dict[str, Any]] | None], None] | None - ) = None, ) -> None: """Log compaction result to runtime logger and event bus.""" ratio_after = conversation.usage_ratio() @@ -413,18 +517,17 @@ async def log_compaction( ) ) - if publish_context_usage_fn is not None: - await publish_context_usage_fn(ctx, conversation, "post_compaction") + await publish_context_usage(event_bus, ctx, conversation, "post_compaction") - if write_debug_log_fn is not None and os.environ.get("HIVE_COMPACTION_DEBUG"): - write_debug_log_fn(ctx, before_pct, after_pct, level, pre_inventory) + if os.environ.get("HIVE_COMPACTION_DEBUG"): + write_compaction_debug_log(ctx, before_pct, after_pct, level, pre_inventory) def build_emergency_summary( ctx: NodeContext, - accumulator: Any | None = None, # OutputAccumulator + accumulator: OutputAccumulator | None = None, conversation: NodeConversation | None = None, - config: Any | None = None, # LoopConfig + config: LoopConfig | None = None, ) -> str: """Build a structured emergency compaction summary. diff --git a/core/framework/graph/event_loop/cursor_persistence.py b/core/framework/graph/event_loop/cursor_persistence.py index bbbe1de149..737754c322 100644 --- a/core/framework/graph/event_loop/cursor_persistence.py +++ b/core/framework/graph/event_loop/cursor_persistence.py @@ -14,7 +14,8 @@ from dataclasses import dataclass from typing import Any -from framework.graph.conversation import NodeConversation +from framework.graph.conversation import ConversationStore, NodeConversation +from framework.graph.event_loop.types import LoopConfig, OutputAccumulator, TriggerEvent from framework.graph.node import NodeContext from framework.llm.capabilities import supports_image_tool_results @@ -26,16 +27,16 @@ class RestoredState: """State recovered from a previous checkpoint.""" conversation: NodeConversation - accumulator: Any # OutputAccumulator + accumulator: OutputAccumulator start_iteration: int recent_responses: list[str] recent_tool_fingerprints: list[list[tuple[str, str]]] async def restore( - conversation_store: Any | None, # ConversationStore + conversation_store: ConversationStore | None, ctx: NodeContext, - config: Any, # LoopConfig + config: LoopConfig, ) -> RestoredState | None: """Attempt to restore from a previous checkpoint. @@ -46,9 +47,6 @@ async def restore( if conversation_store is None: return None - # Import here to avoid circular imports at module level - from framework.graph.event_loop_node import OutputAccumulator - # In isolated mode, filter parts by phase_id so the node only sees # its own messages in the shared flat conversation store. In # continuous mode (or when _restore is called for timer-resume) @@ -94,10 +92,10 @@ async def restore( async def write_cursor( - conversation_store: Any | None, # ConversationStore + conversation_store: ConversationStore | None, ctx: NodeContext, conversation: NodeConversation, - accumulator: Any, # OutputAccumulator + accumulator: OutputAccumulator, iteration: int, *, recent_responses: list[str] | None = None, @@ -186,9 +184,6 @@ async def drain_trigger_queue( Multiple triggers are merged so the LLM sees them atomically and can reason about all pending triggers before acting. """ - # Import here to avoid circular imports at module level - from framework.graph.event_loop_node import TriggerEvent - triggers: list[TriggerEvent] = [] while not queue.empty(): try: diff --git a/core/framework/graph/event_loop/event_publishing.py b/core/framework/graph/event_loop/event_publishing.py index 6674b3ab60..52303fad12 100644 --- a/core/framework/graph/event_loop/event_publishing.py +++ b/core/framework/graph/event_loop/event_publishing.py @@ -8,8 +8,9 @@ import logging import time -from typing import Any +from framework.graph.conversation import NodeConversation +from framework.graph.event_loop.types import HookContext from framework.graph.node import NodeContext from framework.runtime.event_bus import EventBus @@ -166,6 +167,38 @@ async def publish_loop_completed( ) +async def publish_context_usage( + event_bus: EventBus | None, + ctx: NodeContext, + conversation: NodeConversation, + trigger: str, +) -> None: + """Emit a CONTEXT_USAGE_UPDATED event with current context window state.""" + if not event_bus: + return + + from framework.runtime.event_bus import AgentEvent, EventType + + estimated = conversation.estimate_tokens() + max_tokens = conversation._max_context_tokens + ratio = estimated / max_tokens if max_tokens > 0 else 0.0 + await event_bus.publish( + AgentEvent( + type=EventType.CONTEXT_USAGE_UPDATED, + stream_id=ctx.stream_id or ctx.node_id, + node_id=ctx.node_id, + data={ + "usage_ratio": round(ratio, 4), + "usage_pct": round(ratio * 100), + "message_count": conversation.message_count, + "estimated_tokens": estimated, + "max_context_tokens": max_tokens, + "trigger": trigger, + }, + ) + ) + + async def publish_stalled( event_bus: EventBus | None, stream_id: str, @@ -294,7 +327,7 @@ async def publish_output_key_set( async def run_hooks( hooks_config: dict[str, list], event: str, - conversation: Any, # NodeConversation + conversation: NodeConversation, trigger: str | None = None, ) -> None: """Run all registered hooks for *event*, applying their results. @@ -305,9 +338,6 @@ async def run_hooks( Hooks run in registration order; each sees the prompt as left by the previous hook. """ - # Import here to avoid circular deps at module level - from framework.graph.event_loop_node import HookContext - hook_list = hooks_config.get(event, []) if not hook_list: return diff --git a/core/framework/graph/event_loop/judge_pipeline.py b/core/framework/graph/event_loop/judge_pipeline.py index 0cd3e2cc96..24a05f694f 100644 --- a/core/framework/graph/event_loop/judge_pipeline.py +++ b/core/framework/graph/event_loop/judge_pipeline.py @@ -1,33 +1,66 @@ -"""Judge evaluation pipeline for the event loop. - -Contains the turn evaluation logic (implicit judge + custom judge dispatch) -and the SubagentJudge implementation. -""" +"""Judge evaluation pipeline for the event loop.""" from __future__ import annotations import logging -from typing import Any +from collections.abc import Callable from framework.graph.conversation import NodeConversation +from framework.graph.event_loop.types import JudgeProtocol, JudgeVerdict, OutputAccumulator from framework.graph.node import NodeContext logger = logging.getLogger(__name__) +class SubagentJudge: + """Judge for subagent execution.""" + + def __init__(self, task: str, max_iterations: int = 10): + self._task = task + self._max_iterations = max_iterations + + async def evaluate(self, context: dict[str, object]) -> JudgeVerdict: + missing = context.get("missing_keys", []) + if not isinstance(missing, list) or not missing: + return JudgeVerdict(action="ACCEPT", feedback="") + + iteration = context.get("iteration", 0) + if not isinstance(iteration, int): + iteration = 0 + remaining = self._max_iterations - iteration - 1 + + if remaining <= 3: + urgency = ( + f"URGENT: Only {remaining} iterations left. " + f"Stop all other work and call set_output NOW for: {missing}" + ) + elif remaining <= self._max_iterations // 2: + urgency = ( + f"WARNING: {remaining} iterations remaining. " + f"You must call set_output for: {missing}" + ) + else: + urgency = f"Missing output keys: {missing}. Use set_output to provide them." + + return JudgeVerdict(action="RETRY", feedback=f"Your task: {self._task}\n{urgency}") + + async def judge_turn( *, mark_complete_flag: bool, - judge: Any | None, # JudgeProtocol + judge: JudgeProtocol | None, ctx: NodeContext, conversation: NodeConversation, - accumulator: Any, # OutputAccumulator + accumulator: OutputAccumulator, assistant_text: str, - tool_results: list[dict], + tool_results: list[dict[str, object]], iteration: int, - get_missing_output_keys_fn: Any, # Callable + get_missing_output_keys_fn: Callable[ + [OutputAccumulator, list[str] | None, list[str] | None], + list[str], + ], max_context_tokens: int, -) -> Any: # JudgeVerdict +) -> JudgeVerdict: """Evaluate the current state using judge or implicit logic. Evaluation levels (in order): @@ -41,8 +74,6 @@ async def judge_turn( feedback message. Any non-None feedback (including ``""``) means a real evaluation occurred and will be logged into the conversation. """ - from framework.graph.event_loop_node import JudgeVerdict - # --- Level 0: short-circuits (no evaluation) ----------------------- if mark_complete_flag: diff --git a/core/framework/graph/event_loop/subagent_executor.py b/core/framework/graph/event_loop/subagent_executor.py index f1acd02903..c2723f9326 100644 --- a/core/framework/graph/event_loop/subagent_executor.py +++ b/core/framework/graph/event_loop/subagent_executor.py @@ -11,10 +11,19 @@ import json import logging import time +from collections.abc import Awaitable, Callable from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any +from framework.graph.conversation import ConversationStore +from framework.graph.event_loop.judge_pipeline import SubagentJudge +from framework.graph.event_loop.types import LoopConfig, OutputAccumulator +from framework.graph.node import NodeContext, SharedMemory from framework.llm.provider import ToolResult, ToolUse +from framework.runtime.event_bus import EventBus + +if TYPE_CHECKING: + from framework.graph.event_loop_node import EventLoopNode logger = logging.getLogger(__name__) @@ -35,7 +44,13 @@ def __init__(self) -> None: self._response: str | None = None self._awaiting_input = True # So inject_worker_message() can prefer us - async def inject_event(self, content: str, *, is_client_input: bool = False) -> None: + async def inject_event( + self, + content: str, + *, + is_client_input: bool = False, + image_content: list[dict[str, Any]] | None = None, + ) -> None: """Called by ExecutionStream.inject_input() when the user responds.""" self._response = content self._event.set() @@ -47,15 +62,17 @@ async def wait(self) -> str | None: async def execute_subagent( - ctx: Any, # NodeContext + ctx: NodeContext, agent_id: str, task: str, *, - accumulator: Any | None = None, # OutputAccumulator - event_bus: Any | None = None, # EventBus - config: Any = None, # LoopConfig - tool_executor: Any | None = None, # Callable - conversation_store: Any | None = None, # ConversationStore + config: LoopConfig, + event_loop_node_cls: type[EventLoopNode], + escalation_receiver_cls: type[EscalationReceiver], + accumulator: OutputAccumulator | None = None, + event_bus: EventBus | None = None, + tool_executor: Callable[[ToolUse], ToolResult | Awaitable[ToolResult]] | None = None, + conversation_store: ConversationStore | None = None, subagent_instance_counter: dict[str, int] | None = None, ) -> ToolResult: """Execute a subagent and return the result as a ToolResult. @@ -80,8 +97,6 @@ async def execute_subagent( Returns: ToolResult with structured JSON output. """ - from framework.graph.node import NodeContext, SharedMemory - # Log subagent invocation start logger.info( "\n" + "=" * 60 + "\n" @@ -177,10 +192,8 @@ async def _report_callback( # Create isolated receiver and register for input routing import uuid - from framework.graph.event_loop_node import _EscalationReceiver - escalation_id = f"{ctx.node_id}:escalation:{uuid.uuid4().hex[:8]}" - receiver = _EscalationReceiver() + receiver = escalation_receiver_cls() registry = ctx.shared_node_registry registry[escalation_id] = receiver @@ -278,10 +291,7 @@ async def _report_callback( if config.spillover_dir: subagent_spillover = str(Path(config.spillover_dir) / agent_id / subagent_instance) - # Import here to avoid circular imports at module level - from framework.graph.event_loop_node import EventLoopNode, LoopConfig, SubagentJudge - - subagent_node = EventLoopNode( + subagent_node = event_loop_node_cls( event_bus=event_bus, judge=SubagentJudge(task=task, max_iterations=max_iter), config=LoopConfig( From d648f3d3159f13810fe92104a2e7617f59fc8b22 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Tue, 24 Mar 2026 01:00:08 +0530 Subject: [PATCH 07/10] refactor(event-loop): slim event loop node orchestration --- core/framework/graph/event_loop_node.py | 624 ++++-------------------- 1 file changed, 84 insertions(+), 540 deletions(-) diff --git a/core/framework/graph/event_loop_node.py b/core/framework/graph/event_loop_node.py index 2ef0cb16ae..505386a58f 100644 --- a/core/framework/graph/event_loop_node.py +++ b/core/framework/graph/event_loop_node.py @@ -18,12 +18,78 @@ import re import time from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path -from typing import Any, Literal, Protocol, runtime_checkable +from typing import Any from framework.graph.conversation import ConversationStore, NodeConversation +from framework.graph.event_loop import types as event_loop_types +from framework.graph.event_loop.compaction import ( + _llm_compact_split, + build_emergency_summary, + build_llm_compaction_prompt, + compact, + format_messages_for_summary, + llm_compact, +) +from framework.graph.event_loop.cursor_persistence import ( + RestoredState, + check_pause, + drain_injection_queue, + drain_trigger_queue, + restore, + write_cursor, +) +from framework.graph.event_loop.event_publishing import ( + generate_action_plan, + log_skip_judge, + publish_context_usage, + publish_iteration, + publish_judge_verdict, + publish_llm_turn_complete, + publish_loop_completed, + publish_loop_started, + publish_output_key_set, + publish_stalled, + publish_text_delta, + publish_tool_completed, + publish_tool_started, + run_hooks, +) +from framework.graph.event_loop.judge_pipeline import ( + SubagentJudge as SharedSubagentJudge, + judge_turn, +) +from framework.graph.event_loop.stall_detector import ( + fingerprint_tool_calls, + is_stalled, + is_tool_doom_loop, + ngram_similarity, +) +from framework.graph.event_loop.subagent_executor import execute_subagent +from framework.graph.event_loop.synthetic_tools import ( + build_ask_user_multiple_tool, + build_ask_user_tool, + build_delegate_tool, + build_escalate_tool, + build_report_to_parent_tool, + build_set_output_tool, + handle_set_output, +) +from framework.graph.event_loop.tool_result_handler import ( + build_json_preview, + execute_tool, + extract_json_metadata, + is_transient_error, + record_learning, + restore_spill_counter, + truncate_tool_result, +) +from framework.graph.event_loop.types import ( + JudgeProtocol, + JudgeVerdict, + TriggerEvent, +) from framework.graph.node import NodeContext, NodeProtocol, NodeResult from framework.llm.capabilities import supports_image_tool_results from framework.llm.provider import Tool, ToolResult, ToolUse @@ -73,7 +139,7 @@ async def _describe_images_as_text(image_content: list[dict[str, Any]]) -> str | if description: count = len(image_content) label = "image" if count == 1 else f"{count} images" - return f"[{label} attached - description: {description}]" + return f"[{label} attached \u2014 description: {description}]" except Exception as exc: logger.debug("Vision fallback model '%s' failed: %s", model, exc) continue @@ -81,20 +147,6 @@ async def _describe_images_as_text(image_content: list[dict[str, Any]]) -> str | return None -@dataclass -class TriggerEvent: - """A framework-level trigger signal (timer tick or webhook hit). - - Triggers are queued separately from user messages / external events - and drained atomically so the LLM sees all pending triggers at once. - """ - - trigger_type: str # "timer" | "webhook" - source_id: str # entry point ID or webhook route ID - payload: dict[str, Any] = field(default_factory=dict) - timestamp: float = field(default_factory=time.time) - - # Pattern for detecting context-window-exceeded errors across LLM providers. _CONTEXT_TOO_LARGE_RE = re.compile( r"context.{0,20}(length|window|limit|size)|" @@ -162,268 +214,12 @@ class TurnCancelled(Exception): pass -@dataclass -class JudgeVerdict: - """Result of judge evaluation for the event loop.""" - - action: Literal["ACCEPT", "RETRY", "ESCALATE"] - # None = no evaluation happened (skip_judge, tool-continue); not logged. - # "" = evaluated but no feedback; logged with default text. - # "..." = evaluated with feedback; logged as-is. - feedback: str | None = None - - -@runtime_checkable -class JudgeProtocol(Protocol): - """Protocol for event-loop judges. - - Implementations evaluate the current state of the event loop and - decide whether to accept the output, retry with feedback, or escalate. - """ - - async def evaluate(self, context: dict[str, Any]) -> JudgeVerdict: ... - - -class SubagentJudge: - """Judge for subagent execution. - - Accepts immediately when all required output keys are filled, - regardless of whether real tool calls were also made in the same turn. - On RETRY, reminds the subagent of its specific task with progressive - urgency based on remaining iterations. - """ - - def __init__(self, task: str, max_iterations: int = 10): - self._task = task - self._max_iterations = max_iterations - - async def evaluate(self, context: dict[str, Any]) -> JudgeVerdict: - missing = context.get("missing_keys", []) - if not missing: - return JudgeVerdict(action="ACCEPT", feedback="") - - iteration = context.get("iteration", 0) - remaining = self._max_iterations - iteration - 1 - - if remaining <= 3: - urgency = ( - f"URGENT: Only {remaining} iterations left. " - f"Stop all other work and call set_output NOW for: {missing}" - ) - elif remaining <= self._max_iterations // 2: - urgency = ( - f"WARNING: {remaining} iterations remaining. " - f"You must call set_output for: {missing}" - ) - else: - urgency = f"Missing output keys: {missing}. Use set_output to provide them." - - return JudgeVerdict(action="RETRY", feedback=f"Your task: {self._task}\n{urgency}") - - -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - - -@dataclass -class LoopConfig: - """Configuration for the event loop.""" - - max_iterations: int = 50 - max_tool_calls_per_turn: int = 30 - judge_every_n_turns: int = 1 - stall_detection_threshold: int = 3 - stall_similarity_threshold: float = 0.85 - max_context_tokens: int = 32_000 - store_prefix: str = "" - - # Overflow margin for max_tool_calls_per_turn. Tool calls are only - # discarded when the count exceeds max_tool_calls_per_turn * (1 + margin). - # Default 0.5 means 50% wiggle room (e.g. limit=10 → hard cutoff at 15). - tool_call_overflow_margin: float = 0.5 - - # --- Tool result context management --- - # When a tool result exceeds this character count, it is truncated in the - # conversation context. If *spillover_dir* is set the full result is - # written to a file and the truncated message includes the filename so - # the agent can retrieve it with load_data(). If *spillover_dir* is - # ``None`` the result is simply truncated with an explanatory note. - max_tool_result_chars: int = 30_000 - spillover_dir: str | None = None # Path string; created on first use - - # --- set_output value spilling --- - # When a set_output value exceeds this character count it is auto-saved - # to a file in *spillover_dir* and the stored value is replaced with a - # lightweight file reference. This keeps shared memory / adapt.md / - # transition markers small and forces the next node to load the full - # data from the file. Set to 0 to disable. - max_output_value_chars: int = 2_000 - - # --- Stream retry (transient error recovery within EventLoopNode) --- - # When _run_single_turn() raises a transient error (network, rate limit, - # server error), retry up to this many times with exponential backoff - # before re-raising. Set to 0 to disable. - max_stream_retries: int = 3 - stream_retry_backoff_base: float = 2.0 - stream_retry_max_delay: float = 60.0 # cap per-retry sleep - - # --- Tool doom loop detection --- - # Detect when the LLM calls the same tool(s) with identical args for - # N consecutive turns. For client-facing nodes, blocks for user input. - # For non-client-facing nodes, injects a warning into the conversation. - tool_doom_loop_threshold: int = 3 - - # --- Client-facing auto-block grace period --- - # When a client-facing node produces text-only turns (no tools, no - # set_output), the judge is skipped for this many consecutive auto-block - # turns. After the grace period, the judge runs to apply RETRY pressure - # on models stuck in a clarification loop. Explicit ask_user() calls - # always skip the judge regardless of this setting. - cf_grace_turns: int = 1 - tool_doom_loop_enabled: bool = True - - # --- Per-tool-call timeout --- - # Maximum seconds a single tool call may take before being killed. - # Prevents hung MCP servers (especially browser/GCU tools) from - # blocking the entire event loop indefinitely. 0 = no timeout. - tool_call_timeout_seconds: float = 60.0 - - # --- Subagent delegation timeout --- - # Maximum seconds a delegate_to_sub_agent call may run before being - # killed. Subagents run a full event-loop so they naturally take - # longer than a single tool call — default is 10 minutes. 0 = no timeout. - subagent_timeout_seconds: float = 600.0 - - # --- Lifecycle hooks --- - # Hooks are async callables keyed by event name. Supported events: - # "session_start" — fires once after the first user message is added, - # before the first LLM turn. trigger = initial message. - # "external_message" — fires when inject_notification() delivers a message. - # trigger = injected message text. - # Each hook receives a HookContext and may return a HookResult to patch - # the system prompt and/or inject a follow-up user message. - hooks: dict[str, list] = None # dict[str, list[HookFn]] (None → no hooks) - - def __post_init__(self) -> None: - if self.hooks is None: - object.__setattr__(self, "hooks", {}) - - -# --------------------------------------------------------------------------- -# Hook types -# --------------------------------------------------------------------------- - - -@dataclass -class HookContext: - """Context passed to every lifecycle hook.""" - - event: str # event name, e.g. "session_start" - trigger: str | None # message that triggered the hook, if any - system_prompt: str # current system prompt at hook invocation time - - -@dataclass -class HookResult: - """What a hook may return to modify node state.""" - - system_prompt: str | None = None # replace current system prompt - inject: str | None = None # inject an additional user message - - -# --------------------------------------------------------------------------- -# Output accumulator with write-through persistence -# --------------------------------------------------------------------------- - - -@dataclass -class OutputAccumulator: - """Accumulates output key-value pairs with optional write-through persistence. - - Values are stored in memory and optionally written through to a - ConversationStore's cursor data for crash recovery. - - When *spillover_dir* and *max_value_chars* are set, large values are - automatically saved to files and replaced with lightweight file - references. This guarantees auto-spill fires on **every** ``set()`` - call regardless of code path (resume, checkpoint restore, etc.). - """ - - values: dict[str, Any] = field(default_factory=dict) - store: ConversationStore | None = None - spillover_dir: str | None = None - max_value_chars: int = 0 # 0 = disabled - - async def set(self, key: str, value: Any) -> None: - """Set a key-value pair, auto-spilling large values to files. - - When the serialised value exceeds *max_value_chars*, the data is - saved to ``/output_.`` and *value* is - replaced with a compact file-reference string. - """ - value = self._auto_spill(key, value) - self.values[key] = value - if self.store: - cursor = await self.store.read_cursor() or {} - outputs = cursor.get("outputs", {}) - outputs[key] = value - cursor["outputs"] = outputs - await self.store.write_cursor(cursor) - - def _auto_spill(self, key: str, value: Any) -> Any: - """Save large values to a file and return a reference string.""" - if self.max_value_chars <= 0 or not self.spillover_dir: - return value - - val_str = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value - if len(val_str) <= self.max_value_chars: - return value - - spill_path = Path(self.spillover_dir) - spill_path.mkdir(parents=True, exist_ok=True) - ext = ".json" if isinstance(value, (dict, list)) else ".txt" - filename = f"output_{key}{ext}" - write_content = ( - json.dumps(value, indent=2, ensure_ascii=False) - if isinstance(value, (dict, list)) - else str(value) - ) - (spill_path / filename).write_text(write_content, encoding="utf-8") - file_size = (spill_path / filename).stat().st_size - logger.info( - "set_output value auto-spilled: key=%s, %d chars → %s (%d bytes)", - key, - len(val_str), - filename, - file_size, - ) - return ( - f"[Saved to '{filename}' ({file_size:,} bytes). " - f"Use load_data(filename='{filename}') " - f"to access full data.]" - ) - - def get(self, key: str) -> Any | None: - """Get a value by key, or None if not present.""" - return self.values.get(key) - - def to_dict(self) -> dict[str, Any]: - """Return a copy of all accumulated values.""" - return dict(self.values) - - def has_all_keys(self, required: list[str]) -> bool: - """Check if all required keys have been set (non-None).""" - return all(key in self.values and self.values[key] is not None for key in required) - - @classmethod - async def restore(cls, store: ConversationStore) -> OutputAccumulator: - """Restore an OutputAccumulator from a store's cursor data.""" - cursor = await store.read_cursor() - values = {} - if cursor and "outputs" in cursor: - values = cursor["outputs"] - return cls(values=values, store=store) +# Re-export shared event-loop types from the legacy parent module. +SubagentJudge = SharedSubagentJudge +LoopConfig = event_loop_types.LoopConfig +HookContext = event_loop_types.HookContext +HookResult = event_loop_types.HookResult +OutputAccumulator = event_loop_types.OutputAccumulator # --------------------------------------------------------------------------- @@ -2983,40 +2779,28 @@ async def _timed_subagent( def _build_ask_user_tool(self) -> Tool: """Build the synthetic ask_user tool. Delegates to synthetic_tools module.""" - from framework.graph.event_loop.synthetic_tools import build_ask_user_tool - return build_ask_user_tool() def _build_ask_user_multiple_tool(self) -> Tool: """Build the synthetic ask_user_multiple tool. Delegates to synthetic_tools module.""" - from framework.graph.event_loop.synthetic_tools import build_ask_user_multiple_tool - return build_ask_user_multiple_tool() def _build_set_output_tool(self, output_keys: list[str] | None) -> Tool | None: """Build the synthetic set_output tool. Delegates to synthetic_tools module.""" - from framework.graph.event_loop.synthetic_tools import build_set_output_tool - return build_set_output_tool(output_keys) def _build_escalate_tool(self) -> Tool: """Build the synthetic escalate tool. Delegates to synthetic_tools module.""" - from framework.graph.event_loop.synthetic_tools import build_escalate_tool - return build_escalate_tool() def _build_delegate_tool( self, sub_agents: list[str], node_registry: dict[str, Any] ) -> Tool | None: """Build the synthetic delegate_to_sub_agent tool. Delegates to synthetic_tools module.""" - from framework.graph.event_loop.synthetic_tools import build_delegate_tool - return build_delegate_tool(sub_agents, node_registry) def _build_report_to_parent_tool(self) -> Tool: """Build the synthetic report_to_parent tool. Delegates to synthetic_tools module.""" - from framework.graph.event_loop.synthetic_tools import build_report_to_parent_tool - return build_report_to_parent_tool() def _handle_set_output( @@ -3025,8 +2809,6 @@ def _handle_set_output( output_keys: list[str] | None, ) -> ToolResult: """Handle set_output tool call. Delegates to synthetic_tools module.""" - from framework.graph.event_loop.synthetic_tools import handle_set_output - return handle_set_output(tool_input, output_keys) # ------------------------------------------------------------------- @@ -3043,8 +2825,6 @@ async def _judge_turn( iteration: int, ) -> JudgeVerdict: """Evaluate the current state. Delegates to judge_pipeline module.""" - from framework.graph.event_loop.judge_pipeline import judge_turn - return await judge_turn( mark_complete_flag=self._mark_complete_flag, judge=self._judge, @@ -3114,14 +2894,10 @@ def _get_missing_output_keys( @staticmethod def _ngram_similarity(s1: str, s2: str, n: int = 2) -> float: """Jaccard similarity of n-gram sets. Delegates to stall_detector module.""" - from framework.graph.event_loop.stall_detector import ngram_similarity - return ngram_similarity(s1, s2, n) def _is_stalled(self, recent_responses: list[str]) -> bool: """Detect stall using n-gram similarity. Delegates to stall_detector module.""" - from framework.graph.event_loop.stall_detector import is_stalled - return is_stalled( recent_responses, self._config.stall_detection_threshold, @@ -3131,8 +2907,6 @@ def _is_stalled(self, recent_responses: list[str]) -> bool: @staticmethod def _is_transient_error(exc: BaseException) -> bool: """Classify whether an exception is transient. Delegates to tool_result_handler module.""" - from framework.graph.event_loop.tool_result_handler import is_transient_error - return is_transient_error(exc) @staticmethod @@ -3140,8 +2914,6 @@ def _fingerprint_tool_calls( tool_results: list[dict], ) -> list[tuple[str, str]]: """Create deterministic fingerprints. Delegates to stall_detector module.""" - from framework.graph.event_loop.stall_detector import fingerprint_tool_calls - return fingerprint_tool_calls(tool_results) def _is_tool_doom_loop( @@ -3149,8 +2921,6 @@ def _is_tool_doom_loop( recent_tool_fingerprints: list[list[tuple[str, str]]], ) -> tuple[bool, str]: """Detect doom loop. Delegates to stall_detector module.""" - from framework.graph.event_loop.stall_detector import is_tool_doom_loop - return is_tool_doom_loop( recent_tool_fingerprints=recent_tool_fingerprints, threshold=self._config.tool_doom_loop_threshold, @@ -3166,8 +2936,6 @@ async def _execute_tool(self, tc: ToolCallEvent) -> ToolResult: sync executors (MCP STDIO tools that block on ``future.result()``) don't freeze the event loop. """ - from framework.graph.event_loop.tool_result_handler import execute_tool - return await execute_tool( tool_executor=self._tool_executor, tc=tc, @@ -3183,8 +2951,6 @@ def _record_learning(self, key: str, value: Any) -> None: adapt.md is injected into the system prompt, these persist through any compaction. """ - from framework.graph.event_loop.tool_result_handler import record_learning - return record_learning( key=key, value=value, @@ -3200,8 +2966,6 @@ def _next_spill_filename(self, tool_name: str) -> str: def _restore_spill_counter(self) -> None: """Scan spillover_dir for existing spill files and restore the counter.""" - from framework.graph.event_loop.tool_result_handler import restore_spill_counter - self._spill_counter = restore_spill_counter( spillover_dir=self._config.spillover_dir, ) @@ -3219,8 +2983,6 @@ def _extract_json_metadata(parsed: Any, *, _depth: int = 0, _max_depth: int = 3) Returns an empty string for simple scalars. """ - from framework.graph.event_loop.tool_result_handler import extract_json_metadata - return extract_json_metadata( parsed=parsed, ) @@ -3234,8 +2996,6 @@ def _build_json_preview(parsed: Any, *, max_chars: int = 5000) -> str | None: Returns ``None`` if no truncation was needed (no large arrays). """ - from framework.graph.event_loop.tool_result_handler import build_json_preview - return build_json_preview( parsed=parsed, max_chars=max_chars, @@ -3258,8 +3018,6 @@ def _truncate_tool_result( - Errors: pass through unchanged - load_data results: truncate with pagination hint (no re-spill) """ - from framework.graph.event_loop.tool_result_handler import truncate_tool_result - return truncate_tool_result( result=result, tool_name=tool_name, @@ -3291,8 +3049,6 @@ async def _compact( does not fully resolve the budget. 4. Emergency deterministic summary only if LLM failed or unavailable. """ - from framework.graph.event_loop.compaction import compact - return await compact( ctx=ctx, conversation=conversation, @@ -3301,9 +3057,6 @@ async def _compact( event_bus=self._event_bus, char_limit=self._LLM_COMPACT_CHAR_LIMIT, max_depth=self._LLM_COMPACT_MAX_DEPTH, - build_message_inventory_fn=self._build_message_inventory, - publish_context_usage_fn=self._publish_context_usage, - write_debug_log_fn=self._write_compaction_debug_log, ) # --- LLM compaction with binary-search splitting ---------------------- @@ -3322,8 +3075,6 @@ async def _llm_compact( in half and each half is summarised independently. Tool history is appended once at the top-level call (``_depth == 0``). """ - from framework.graph.event_loop.compaction import llm_compact - return await llm_compact( ctx=ctx, messages=messages, @@ -3342,8 +3093,6 @@ async def _llm_compact_split( _depth: int, ) -> str: """Split messages in half and summarise each half independently.""" - from framework.graph.event_loop.compaction import _llm_compact_split - return await _llm_compact_split( ctx=ctx, messages=messages, @@ -3359,8 +3108,6 @@ async def _llm_compact_split( @staticmethod def _format_messages_for_summary(messages: list) -> str: """Format messages as text for LLM summarisation.""" - from framework.graph.event_loop.compaction import format_messages_for_summary - return format_messages_for_summary(messages) def _build_llm_compaction_prompt( @@ -3370,8 +3117,6 @@ def _build_llm_compaction_prompt( formatted_messages: str, ) -> str: """Build prompt for LLM compaction targeting 50% of token budget.""" - from framework.graph.event_loop.compaction import build_llm_compaction_prompt - return build_llm_compaction_prompt( ctx, accumulator, @@ -3379,143 +3124,6 @@ def _build_llm_compaction_prompt( max_context_tokens=self._config.max_context_tokens, ) - @staticmethod - def _build_message_inventory( - conversation: NodeConversation, - ) -> list[dict[str, Any]]: - """Build a per-message size inventory for debug logging.""" - inventory: list[dict[str, Any]] = [] - for m in conversation.messages: - content_chars = len(m.content) - tc_chars = 0 - tool_name = None - if m.tool_calls: - for tc in m.tool_calls: - args = tc.get("function", {}).get("arguments", "") - tc_chars += len(args) if isinstance(args, str) else len(json.dumps(args)) - names = [tc.get("function", {}).get("name", "?") for tc in m.tool_calls] - tool_name = ", ".join(names) - elif m.role == "tool" and m.tool_use_id: - for prev in conversation.messages: - if prev.tool_calls: - for tc in prev.tool_calls: - if tc.get("id") == m.tool_use_id: - tool_name = tc.get("function", {}).get("name", "?") - break - if tool_name: - break - entry: dict[str, Any] = { - "seq": m.seq, - "role": m.role, - "content_chars": content_chars, - } - if tc_chars: - entry["tool_call_args_chars"] = tc_chars - if tool_name: - entry["tool"] = tool_name - if m.is_error: - entry["is_error"] = True - if m.phase_id: - entry["phase"] = m.phase_id - if content_chars > 2000: - entry["preview"] = m.content[:200] + "…" - inventory.append(entry) - return inventory - - async def _log_compaction( - self, - ctx: NodeContext, - conversation: NodeConversation, - ratio_before: float, - pre_inventory: list[dict[str, Any]] | None = None, - ) -> None: - """Log compaction result to runtime logger and event bus.""" - from framework.graph.event_loop.compaction import log_compaction - - return await log_compaction( - ctx, - conversation, - ratio_before, - self._event_bus, - pre_inventory=pre_inventory, - publish_context_usage_fn=self._publish_context_usage, - write_debug_log_fn=self._write_compaction_debug_log, - ) - - @staticmethod - def _write_compaction_debug_log( - ctx: NodeContext, - before_pct: int, - after_pct: int, - level: str, - inventory: list[dict[str, Any]] | None, - ) -> None: - """Write detailed compaction analysis to ~/.hive/compaction_log/.""" - log_dir = Path.home() / ".hive" / "compaction_log" - log_dir.mkdir(parents=True, exist_ok=True) - - ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%S_%f") - node_label = ctx.node_id.replace("/", "_") - log_path = log_dir / f"{ts}_{node_label}.md" - - lines: list[str] = [ - f"# Compaction Debug — {ctx.node_id}", - f"**Time:** {datetime.now(UTC).isoformat()}", - f"**Node:** {ctx.node_spec.name} (`{ctx.node_id}`)", - ] - if ctx.stream_id: - lines.append(f"**Stream:** {ctx.stream_id}") - lines.append(f"**Level:** {level}") - lines.append(f"**Usage:** {before_pct}% → {after_pct}%") - lines.append("") - - if inventory: - total_chars = sum( - e.get("content_chars", 0) + e.get("tool_call_args_chars", 0) for e in inventory - ) - lines.append( - f"## Pre-Compaction Message Inventory " - f"({len(inventory)} messages, {total_chars:,} total chars)" - ) - lines.append("") - ranked = sorted( - inventory, - key=lambda e: e.get("content_chars", 0) + e.get("tool_call_args_chars", 0), - reverse=True, - ) - lines.append("| # | seq | role | tool | chars | % of total | flags |") - lines.append("|---|-----|------|------|------:|------------|-------|") - for i, entry in enumerate(ranked, 1): - chars = entry.get("content_chars", 0) + entry.get("tool_call_args_chars", 0) - pct = (chars / total_chars * 100) if total_chars else 0 - tool = entry.get("tool", "") - flags = [] - if entry.get("is_error"): - flags.append("error") - if entry.get("phase"): - flags.append(f"phase={entry['phase']}") - lines.append( - f"| {i} | {entry['seq']} | {entry['role']} | {tool} " - f"| {chars:,} | {pct:.1f}% | {', '.join(flags)} |" - ) - - large = [e for e in ranked if e.get("preview")] - if large: - lines.append("") - lines.append("### Large message previews") - for entry in large: - lines.append( - f"\n**seq={entry['seq']}** ({entry['role']}, {entry.get('tool', '')}):" - ) - lines.append(f"```\n{entry['preview']}\n```") - lines.append("") - - try: - log_path.write_text("\n".join(lines), encoding="utf-8") - logger.debug("Compaction debug log written to %s", log_path) - except OSError: - logger.debug("Failed to write compaction debug log to %s", log_path) - def _build_emergency_summary( self, ctx: NodeContext, @@ -3530,36 +3138,22 @@ def _build_emergency_summary( node's known state so the LLM can continue working after compaction without losing track of its task and inputs. """ - from framework.graph.event_loop.compaction import build_emergency_summary - return build_emergency_summary(ctx, accumulator, conversation, self._config) # ------------------------------------------------------------------- # Persistence: restore, cursor, injection, pause # ------------------------------------------------------------------- - @dataclass - class _RestoredState: - """State recovered from a previous checkpoint.""" - - conversation: NodeConversation - accumulator: OutputAccumulator - start_iteration: int - recent_responses: list[str] - recent_tool_fingerprints: list[list[tuple[str, str]]] - async def _restore( self, ctx: NodeContext, - ) -> _RestoredState | None: + ) -> RestoredState | None: """Attempt to restore from a previous checkpoint. - Returns a ``_RestoredState`` with conversation, accumulator, iteration + Returns a ``RestoredState`` with conversation, accumulator, iteration counter, and stall/doom-loop detection state — everything needed to resume exactly where execution stopped. """ - from framework.graph.event_loop.cursor_persistence import restore - return await restore( conversation_store=self._conversation_store, ctx=ctx, @@ -3581,8 +3175,6 @@ async def _write_cursor( Persists iteration counter, accumulator outputs, and stall/doom-loop detection state so that resume picks up exactly where execution stopped. """ - from framework.graph.event_loop.cursor_persistence import write_cursor - return await write_cursor( conversation_store=self._conversation_store, ctx=ctx, @@ -3595,8 +3187,6 @@ async def _write_cursor( async def _drain_injection_queue(self, conversation: NodeConversation, ctx: NodeContext) -> int: """Drain all pending injected events as user messages. Returns count.""" - from framework.graph.event_loop.cursor_persistence import drain_injection_queue - return await drain_injection_queue( queue=self._injection_queue, conversation=conversation, @@ -3610,8 +3200,6 @@ async def _drain_trigger_queue(self, conversation: NodeConversation) -> int: Multiple triggers are merged so the LLM sees them atomically and can reason about all pending triggers before acting. """ - from framework.graph.event_loop.cursor_persistence import drain_trigger_queue - return await drain_trigger_queue( queue=self._trigger_queue, conversation=conversation, @@ -3629,8 +3217,6 @@ async def _check_pause( Note: This check happens BEFORE starting iteration N, after completing N-1. If paused, the node exits having completed {iteration} iterations (0 to iteration-1). """ - from framework.graph.event_loop.cursor_persistence import check_pause - return await check_pause( ctx=ctx, conversation=conversation, @@ -3644,8 +3230,6 @@ async def _check_pause( async def _publish_loop_started( self, stream_id: str, node_id: str, execution_id: str = "" ) -> None: - from framework.graph.event_loop.event_publishing import publish_loop_started - return await publish_loop_started( event_bus=self._event_bus, stream_id=stream_id, @@ -3665,8 +3249,6 @@ async def _generate_action_plan( Runs as a fire-and-forget task so it never blocks the main loop. """ - from framework.graph.event_loop.event_publishing import generate_action_plan - return await generate_action_plan( event_bus=self._event_bus, ctx=ctx, @@ -3689,8 +3271,6 @@ async def _run_hooks( Hooks run in registration order; each sees the prompt as left by the previous hook. """ - from framework.graph.event_loop.event_publishing import run_hooks - return await run_hooks( hooks_config=self._config.hooks, event=event, @@ -3705,27 +3285,11 @@ async def _publish_context_usage( trigger: str, ) -> None: """Emit a CONTEXT_USAGE_UPDATED event with current context window state.""" - if not self._event_bus: - return - from framework.runtime.event_bus import AgentEvent, EventType - - estimated = conversation.estimate_tokens() - max_tokens = conversation._max_context_tokens - ratio = estimated / max_tokens if max_tokens > 0 else 0.0 - await self._event_bus.publish( - AgentEvent( - type=EventType.CONTEXT_USAGE_UPDATED, - stream_id=ctx.stream_id or ctx.node_id, - node_id=ctx.node_id, - data={ - "usage_ratio": round(ratio, 4), - "usage_pct": round(ratio * 100), - "message_count": conversation.message_count, - "estimated_tokens": estimated, - "max_context_tokens": max_tokens, - "trigger": trigger, - }, - ) + return await publish_context_usage( + event_bus=self._event_bus, + ctx=ctx, + conversation=conversation, + trigger=trigger, ) async def _publish_iteration( @@ -3736,8 +3300,6 @@ async def _publish_iteration( execution_id: str = "", extra_data: dict | None = None, ) -> None: - from framework.graph.event_loop.event_publishing import publish_iteration - return await publish_iteration( event_bus=self._event_bus, stream_id=stream_id, @@ -3759,8 +3321,6 @@ async def _publish_llm_turn_complete( execution_id: str = "", iteration: int | None = None, ) -> None: - from framework.graph.event_loop.event_publishing import publish_llm_turn_complete - return await publish_llm_turn_complete( event_bus=self._event_bus, stream_id=stream_id, @@ -3786,8 +3346,6 @@ def _log_skip_judge( iter_start: float, ) -> None: """Log a CONTINUE step that skips judge evaluation (e.g., waiting for input).""" - from framework.graph.event_loop.event_publishing import log_skip_judge - return log_skip_judge( ctx=ctx, node_id=node_id, @@ -3802,8 +3360,6 @@ def _log_skip_judge( async def _publish_loop_completed( self, stream_id: str, node_id: str, iterations: int, execution_id: str = "" ) -> None: - from framework.graph.event_loop.event_publishing import publish_loop_completed - return await publish_loop_completed( event_bus=self._event_bus, stream_id=stream_id, @@ -3813,8 +3369,6 @@ async def _publish_loop_completed( ) async def _publish_stalled(self, stream_id: str, node_id: str, execution_id: str = "") -> None: - from framework.graph.event_loop.event_publishing import publish_stalled - return await publish_stalled( event_bus=self._event_bus, stream_id=stream_id, @@ -3833,8 +3387,6 @@ async def _publish_text_delta( iteration: int | None = None, inner_turn: int = 0, ) -> None: - from framework.graph.event_loop.event_publishing import publish_text_delta - return await publish_text_delta( event_bus=self._event_bus, stream_id=stream_id, @@ -3856,8 +3408,6 @@ async def _publish_tool_started( tool_input: dict, execution_id: str = "", ) -> None: - from framework.graph.event_loop.event_publishing import publish_tool_started - return await publish_tool_started( event_bus=self._event_bus, stream_id=stream_id, @@ -3878,8 +3428,6 @@ async def _publish_tool_completed( is_error: bool, execution_id: str = "", ) -> None: - from framework.graph.event_loop.event_publishing import publish_tool_completed - return await publish_tool_completed( event_bus=self._event_bus, stream_id=stream_id, @@ -3901,8 +3449,6 @@ async def _publish_judge_verdict( iteration: int = 0, execution_id: str = "", ) -> None: - from framework.graph.event_loop.event_publishing import publish_judge_verdict - return await publish_judge_verdict( event_bus=self._event_bus, stream_id=stream_id, @@ -3921,8 +3467,6 @@ async def _publish_output_key_set( key: str, execution_id: str = "", ) -> None: - from framework.graph.event_loop.event_publishing import publish_output_key_set - return await publish_output_key_set( event_bus=self._event_bus, stream_id=stream_id, @@ -3965,8 +3509,6 @@ async def _execute_subagent( - data: Subagent's output (free-form JSON) - metadata: Execution metadata (success, tokens, latency) """ - from framework.graph.event_loop.subagent_executor import execute_subagent - return await execute_subagent( ctx=ctx, agent_id=agent_id, @@ -3977,4 +3519,6 @@ async def _execute_subagent( tool_executor=self._tool_executor, conversation_store=self._conversation_store, subagent_instance_counter=self._subagent_instance_counter, + event_loop_node_cls=type(self), + escalation_receiver_cls=_EscalationReceiver, ) From e188c26e9f36a0af4921d51a5e6e777da50d688e Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Thu, 26 Mar 2026 08:11:39 +0530 Subject: [PATCH 08/10] chore: revert changes --- core/framework/graph/event_loop_node.py | 2 +- core/frontend/package-lock.json | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/core/framework/graph/event_loop_node.py b/core/framework/graph/event_loop_node.py index 505386a58f..22f8965d2d 100644 --- a/core/framework/graph/event_loop_node.py +++ b/core/framework/graph/event_loop_node.py @@ -139,7 +139,7 @@ async def _describe_images_as_text(image_content: list[dict[str, Any]]) -> str | if description: count = len(image_content) label = "image" if count == 1 else f"{count} images" - return f"[{label} attached \u2014 description: {description}]" + return f"[{label} attached — description: {description}]" except Exception as exc: logger.debug("Vision fallback model '%s' failed: %s", model, exc) continue diff --git a/core/frontend/package-lock.json b/core/frontend/package-lock.json index 1424796cf2..c3f264e3c7 100644 --- a/core/frontend/package-lock.json +++ b/core/frontend/package-lock.json @@ -60,7 +60,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1557,7 +1556,6 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1573,7 +1571,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1786,7 +1783,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3564,7 +3560,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3616,7 +3611,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3629,7 +3623,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4190,7 +4183,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", From 9667dd25cb070d56b7e07b91d5a29f4295775b3a Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Thu, 26 Mar 2026 21:54:56 +0530 Subject: [PATCH 09/10] chore: pull latest changes --- CONTRIBUTING.md | 2 +- README.md | 8 +- core/codex_oauth.py | 108 +- .../agents/credential_tester/agent.py | 31 +- core/framework/agents/queen/queen_memory.py | 8 +- core/framework/cli.py | 5 + core/framework/credentials/setup.py | 29 +- core/framework/graph/safe_eval.py | 4 - core/framework/observability/logging.py | 10 + core/framework/runner/__init__.py | 2 + core/framework/runner/cli.py | 37 +- .../runner/mcp_connection_manager.py | 224 ++- core/framework/runner/mcp_registry.py | 815 ++++++++ core/framework/runner/mcp_registry_cli.py | 906 +++++++++ core/framework/runner/runner.py | 68 +- core/framework/runner/tool_registry.py | 125 +- core/framework/runtime/event_bus.py | 4 +- core/framework/server/queen_orchestrator.py | 11 + core/frontend/src/App.tsx | 2 + core/frontend/src/pages/not-found.tsx | 19 + core/pyproject.toml | 2 +- core/tests/test_agent_runner_mcp_registry.py | 243 +++ core/tests/test_codex_oauth.py | 68 + core/tests/test_mcp_connection_manager.py | 148 ++ core/tests/test_mcp_registry.py | 1172 +++++++++++ core/tests/test_mcp_registry_cli.py | 1331 +++++++++++++ core/tests/test_runner_cli_frontend.py | 60 + core/tests/test_tool_registry.py | 187 ++ ...en Bee Outcome Evaluation - Generation.csv | 1415 ------------- docs/articles/README.md | 30 - docs/articles/aden-vs-autogen.md | 366 ---- docs/articles/aden-vs-crewai.md | 346 ---- docs/articles/aden-vs-langchain.md | 266 --- .../ai-agent-cost-management-guide.md | 465 ----- .../ai-agent-observability-monitoring.md | 423 ---- .../self-improving-vs-static-agents.md | 415 ---- .../top-10-ai-agent-frameworks-2025.md | 326 --- docs/cleanup-plan.md | 161 -- docs/credential-identity-plan.md | 981 --------- docs/credential-store-design.md | 1765 ----------------- docs/credential-store-usage.md | 992 --------- docs/credential-system-analysis.md | 552 ------ docs/developer-guide.md | 227 +-- docs/environment-setup.md | 212 +- docs/getting-started.md | 6 +- docs/hive-coder-meta-agent-plan.md | 110 - docs/issue-local-credential-parity.md | 37 - docs/issue-queen-bee.md | 75 - docs/multi-graph-sessions.md | 288 --- docs/quizzes/00-job-post.md | 157 -- docs/quizzes/01-getting-started.md | 165 -- docs/quizzes/02-architecture-deep-dive.md | 195 -- docs/quizzes/03-build-your-first-agent.md | 277 --- docs/quizzes/04-frontend-challenge.md | 277 --- docs/quizzes/05-devops-challenge.md | 309 --- docs/quizzes/README.md | 54 - docs/server-cli-arch.md | 594 ------ ...structure.py => test_email_reply_agent.py} | 0 ...structure.py => test_meeting_scheduler.py} | 0 quickstart.ps1 | 56 +- quickstart.sh | 22 +- scripts/setup_worker_model.ps1 | 228 ++- tools/pyproject.toml | 2 +- ...chema_discovery.py => schema_discovery.py} | 0 .../tools/file_system_toolkits/__init__.py | 0 .../aden_tools/tools/openmeteo_tool/README.md | 19 + .../tools/openmeteo_tool/__init__.py | 3 + .../tools/openmeteo_tool/openmeteo_tool.py | 99 + .../pushover_tool/tests/test_pushover_tool.py | 92 +- uv.lock | 4 +- 70 files changed, 6020 insertions(+), 11620 deletions(-) create mode 100644 core/framework/runner/mcp_registry.py create mode 100644 core/framework/runner/mcp_registry_cli.py create mode 100644 core/frontend/src/pages/not-found.tsx create mode 100644 core/tests/test_agent_runner_mcp_registry.py create mode 100644 core/tests/test_codex_oauth.py create mode 100644 core/tests/test_mcp_registry.py create mode 100644 core/tests/test_mcp_registry_cli.py create mode 100644 core/tests/test_runner_cli_frontend.py delete mode 100644 docs/Queen Bee Outcome Evaluation - Generation.csv delete mode 100644 docs/articles/README.md delete mode 100644 docs/articles/aden-vs-autogen.md delete mode 100644 docs/articles/aden-vs-crewai.md delete mode 100644 docs/articles/aden-vs-langchain.md delete mode 100644 docs/articles/ai-agent-cost-management-guide.md delete mode 100644 docs/articles/ai-agent-observability-monitoring.md delete mode 100644 docs/articles/self-improving-vs-static-agents.md delete mode 100644 docs/articles/top-10-ai-agent-frameworks-2025.md delete mode 100644 docs/cleanup-plan.md delete mode 100644 docs/credential-identity-plan.md delete mode 100644 docs/credential-store-design.md delete mode 100644 docs/credential-store-usage.md delete mode 100644 docs/credential-system-analysis.md delete mode 100644 docs/hive-coder-meta-agent-plan.md delete mode 100644 docs/issue-local-credential-parity.md delete mode 100644 docs/issue-queen-bee.md delete mode 100644 docs/multi-graph-sessions.md delete mode 100644 docs/quizzes/00-job-post.md delete mode 100644 docs/quizzes/01-getting-started.md delete mode 100644 docs/quizzes/02-architecture-deep-dive.md delete mode 100644 docs/quizzes/03-build-your-first-agent.md delete mode 100644 docs/quizzes/04-frontend-challenge.md delete mode 100644 docs/quizzes/05-devops-challenge.md delete mode 100644 docs/quizzes/README.md delete mode 100644 docs/server-cli-arch.md rename examples/templates/email_reply_agent/tests/{test_structure.py => test_email_reply_agent.py} (100%) rename examples/templates/meeting_scheduler/tests/{test_structure.py => test_meeting_scheduler.py} (100%) rename tools/{test_schema_discovery.py => schema_discovery.py} (100%) create mode 100644 tools/src/aden_tools/tools/file_system_toolkits/__init__.py create mode 100644 tools/src/aden_tools/tools/openmeteo_tool/README.md create mode 100644 tools/src/aden_tools/tools/openmeteo_tool/__init__.py create mode 100644 tools/src/aden_tools/tools/openmeteo_tool/openmeteo_tool.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index acc41d17ca..813f331942 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Welcome to Aden Hive, an open-source AI agent framework built for developers who demand production-grade reliability, cross-platform support, and real-world performance. This guide will help you contribute effectively, whether you're fixing bugs, adding features, improving documentation, or building new tools. -Thank you for your interest in contributing! We're especially looking for help building tools, integrations ([check #2805](https://github.com/adenhq/hive/issues/2805)), and example agents for the framework. +Thank you for your interest in contributing! We're especially looking for help building tools, integrations ([check #2805](https://github.com/aden-hive/hive/issues/2805)), and example agents for the framework. --- diff --git a/README.md b/README.md index 6eb0391868..5b2c5ce9b6 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Use Hive when you need: - **[Self-Hosting Guide](https://docs.adenhq.com/getting-started/quickstart)** - Deploy Hive on your infrastructure - **[Changelog](https://github.com/aden-hive/hive/releases)** - Latest updates and releases - **[Roadmap](docs/roadmap.md)** - Upcoming features and plans -- **[Report Issues](https://github.com/adenhq/hive/issues)** - Bug reports and feature requests +- **[Report Issues](https://github.com/aden-hive/hive/issues)** - Bug reports and feature requests - **[Contributing](CONTRIBUTING.md)** - How to contribute and submit PRs ## Quick Start @@ -100,9 +100,11 @@ Use Hive when you need: git clone https://github.com/aden-hive/hive.git cd hive - -# Run quickstart setup +# Run quickstart setup (macOS/Linux) ./quickstart.sh + +# Windows (PowerShell) +.\quickstart.ps1 ``` This sets up: diff --git a/core/codex_oauth.py b/core/codex_oauth.py index 64b136a8b9..6af9788073 100644 --- a/core/codex_oauth.py +++ b/core/codex_oauth.py @@ -17,6 +17,7 @@ import json import os import platform +import queue import secrets import subprocess import sys @@ -27,6 +28,7 @@ import urllib.request from datetime import UTC, datetime from pathlib import Path +from typing import TextIO # OAuth constants (from the Codex CLI binary) CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" @@ -165,11 +167,11 @@ def open_browser(url: str) -> bool: if system == "Darwin": subprocess.Popen(["open", url], stdout=devnull, stderr=devnull) elif system == "Windows": - subprocess.Popen(["cmd", "/c", "start", url], stdout=devnull, stderr=devnull) + os.startfile(url) # type: ignore[attr-defined] else: subprocess.Popen(["xdg-open", url], stdout=devnull, stderr=devnull) return True - except OSError: + except (AttributeError, OSError): return False @@ -266,6 +268,71 @@ def parse_manual_input(value: str, expected_state: str) -> str | None: return None +def _read_manual_input_lines( + manual_inputs: queue.Queue[str], + stop_event: threading.Event, + stdin: TextIO | None = None, +) -> None: + stream = sys.stdin if stdin is None else stdin + + while not stop_event.is_set(): + try: + manual = stream.readline() + except (EOFError, OSError): + return + + if not manual: + return + + if manual.strip(): + manual_inputs.put(manual) + + +def wait_for_code_from_callback_or_stdin( + expected_state: str, + callback_result: list[str | None], + callback_done: threading.Event, + timeout_secs: float = 120, + poll_interval: float = 0.1, + stdin: TextIO | None = None, +) -> str | None: + manual_inputs: queue.Queue[str] = queue.Queue() + stop_event = threading.Event() + + # Read stdin on a daemon thread so manual paste works on platforms where + # select() cannot poll console handles, including Windows terminals. + threading.Thread( + target=_read_manual_input_lines, + args=(manual_inputs, stop_event, stdin), + daemon=True, + ).start() + + deadline = time.time() + timeout_secs + try: + while time.time() < deadline: + if callback_result[0]: + return callback_result[0] + + while True: + try: + manual = manual_inputs.get_nowait() + except queue.Empty: + break + + code = parse_manual_input(manual, expected_state) + if code: + return code + + if callback_done.is_set(): + return callback_result[0] + + time.sleep(poll_interval) + + return callback_result[0] + finally: + stop_event.set() + + def main() -> int: # Generate PKCE and state verifier, challenge = generate_pkce() @@ -315,41 +382,28 @@ def main() -> int: # Start callback server in background callback_result: list[str | None] = [None] + callback_done = threading.Event() def run_server() -> None: - callback_result[0] = wait_for_callback(state, timeout_secs=120) + try: + callback_result[0] = wait_for_callback(state, timeout_secs=120) + finally: + callback_done.set() server_thread = threading.Thread(target=run_server) server_thread.daemon = True server_thread.start() - # Also accept manual input in parallel - # We poll for both the server result and stdin try: - import select - - while server_thread.is_alive(): - # Check if stdin has data (non-blocking on unix) - if hasattr(select, "select"): - ready, _, _ = select.select([sys.stdin], [], [], 0.5) - if ready: - manual = sys.stdin.readline() - if manual.strip(): - code = parse_manual_input(manual, state) - if code: - break - else: - time.sleep(0.5) - - if callback_result[0]: - code = callback_result[0] - break - except (KeyboardInterrupt, EOFError): + code = wait_for_code_from_callback_or_stdin( + state, + callback_result, + callback_done, + timeout_secs=120, + ) + except KeyboardInterrupt: print("\n\033[0;31mCancelled.\033[0m") return 1 - - if not code: - code = callback_result[0] else: # Manual paste mode try: diff --git a/core/framework/agents/credential_tester/agent.py b/core/framework/agents/credential_tester/agent.py index 4fe68f7652..5527661a81 100644 --- a/core/framework/agents/credential_tester/agent.py +++ b/core/framework/agents/credential_tester/agent.py @@ -16,6 +16,7 @@ from __future__ import annotations +import logging from pathlib import Path from typing import TYPE_CHECKING @@ -25,6 +26,7 @@ from framework.graph.edge import GraphSpec from framework.graph.executor import ExecutionResult from framework.llm import LiteLLMProvider +from framework.runner.mcp_registry import MCPRegistry from framework.runner.tool_registry import ToolRegistry from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime from framework.runtime.execution_stream import EntryPointSpec @@ -32,9 +34,13 @@ from .config import default_config from .nodes import build_tester_node +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from framework.runner import AgentRunner +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Goal # --------------------------------------------------------------------------- @@ -107,7 +113,11 @@ def _list_aden_accounts() -> list[dict]: for c in integrations if c.status == "active" ] + except (ImportError, OSError) as exc: + logger.debug("Could not list Aden accounts: %s", exc) + return [] except Exception: + logger.warning("Unexpected error listing Aden accounts", exc_info=True) return [] @@ -119,7 +129,11 @@ def _list_local_accounts() -> list[dict]: return [ info.to_account_dict() for info in LocalCredentialRegistry.default().list_accounts() ] + except ImportError as exc: + logger.debug("Local credential registry unavailable: %s", exc) + return [] except Exception: + logger.warning("Unexpected error listing local accounts", exc_info=True) return [] @@ -140,7 +154,11 @@ def _list_env_fallback_accounts() -> list[dict]: from framework.credentials.storage import EncryptedFileStorage encrypted_ids: set[str] = set(EncryptedFileStorage().list_all()) + except (ImportError, OSError) as exc: + logger.debug("Could not read encrypted store: %s", exc) + encrypted_ids = set() except Exception: + logger.warning("Unexpected error reading encrypted store", exc_info=True) encrypted_ids = set() def _is_configured(cred_name: str, spec) -> bool: @@ -300,8 +318,10 @@ def _activate_local_account(credential_id: str, alias: str) -> None: if key: os.environ[spec.env_var] = key + except (ImportError, KeyError, OSError) as exc: + logger.debug("Could not inject credentials: %s", exc) except Exception: - pass + logger.warning("Unexpected error injecting credentials", exc_info=True) def _configure_aden_node( @@ -563,6 +583,15 @@ def _setup(self) -> None: if mcp_config_path.exists(): self._tool_registry.load_mcp_config(mcp_config_path) + try: + registry = MCPRegistry() + registry.initialize() + registry_configs = registry.load_agent_selection(Path(__file__).parent) + if registry_configs: + self._tool_registry.load_registry_servers(registry_configs) + except Exception: + logger.warning("MCP registry config failed to load", exc_info=True) + extra_kwargs = getattr(self.config, "extra_kwargs", {}) or {} llm = LiteLLMProvider( model=self.config.model, diff --git a/core/framework/agents/queen/queen_memory.py b/core/framework/agents/queen/queen_memory.py index 5d1f0fb529..3d9f2d8366 100644 --- a/core/framework/agents/queen/queen_memory.py +++ b/core/framework/agents/queen/queen_memory.py @@ -226,7 +226,11 @@ def read_session_context(session_dir: Path, max_messages: int = 80) -> str: elif content: label = "user" if role == "user" else "queen" lines.append(f"[{label}]: {content[:600]}") + except (KeyError, TypeError) as exc: + logger.debug("Skipping malformed conversation message: %s", exc) + continue except Exception: + logger.warning("Unexpected error parsing conversation message", exc_info=True) continue if lines: parts.append("## Conversation\n\n" + "\n".join(lines)) @@ -395,5 +399,5 @@ async def consolidate_queen_memory( f"session: {session_id}\ntime: {datetime.now().isoformat()}\n\n{tb}", encoding="utf-8", ) - except Exception: - pass + except OSError: + pass # Cannot write error file; original exception already logged diff --git a/core/framework/cli.py b/core/framework/cli.py index a814c4bce3..f92c5cdae2 100644 --- a/core/framework/cli.py +++ b/core/framework/cli.py @@ -99,6 +99,11 @@ def main(): register_debugger_commands(subparsers) + # Register MCP registry commands (mcp install, mcp add, ...) + from framework.runner.mcp_registry_cli import register_mcp_commands + + register_mcp_commands(subparsers) + args = parser.parse_args() if hasattr(args, "func"): diff --git a/core/framework/credentials/setup.py b/core/framework/credentials/setup.py index 95ae96d8b9..dfee6bf4a4 100644 --- a/core/framework/credentials/setup.py +++ b/core/framework/credentials/setup.py @@ -27,6 +27,7 @@ import getpass import json +import logging import os import sys from collections.abc import Callable @@ -37,6 +38,8 @@ if TYPE_CHECKING: from framework.graph import NodeSpec +logger = logging.getLogger(__name__) + # ANSI colors for terminal output class Colors: @@ -365,8 +368,11 @@ def _setup_direct_api_key(self, cred: MissingCredential) -> bool: self._print("") try: api_key = self.password_fn(f"Paste your {cred.env_var}: ").strip() + except (EOFError, OSError) as exc: + logger.debug("Password input unavailable, falling back to plain input: %s", exc) + api_key = self._input(f"Paste your {cred.env_var}: ").strip() except Exception: - # Fallback to regular input if password input fails + logger.warning("Unexpected error reading password input", exc_info=True) api_key = self._input(f"Paste your {cred.env_var}: ").strip() if not api_key: @@ -403,7 +409,11 @@ def _setup_via_aden(self, cred: MissingCredential) -> bool: try: aden_key = self.password_fn("Paste your ADEN_API_KEY: ").strip() + except (EOFError, OSError) as exc: + logger.debug("Password input unavailable for ADEN_API_KEY: %s", exc) + aden_key = self._input("Paste your ADEN_API_KEY: ").strip() except Exception: + logger.warning("Unexpected error reading ADEN_API_KEY input", exc_info=True) aden_key = self._input("Paste your ADEN_API_KEY: ").strip() if not aden_key: @@ -433,8 +443,10 @@ def _setup_via_aden(self, cred: MissingCredential) -> bool: value = store.get_key(cred_id, cred.credential_key) if value: os.environ[cred.env_var] = value + except (KeyError, OSError) as exc: + logger.debug("Could not export credential to env: %s", exc) except Exception: - pass + logger.warning("Unexpected error exporting credential to env", exc_info=True) return True else: self._print( @@ -457,9 +469,12 @@ def _run_health_check(self, cred: MissingCredential, value: str) -> dict[str, An "message": result.message, "details": result.details, } - except Exception: + except ImportError: # No health checker available return None + except Exception: + logger.warning("Health check failed for %s", cred.credential_name, exc_info=True) + return None def _store_credential(self, cred: MissingCredential, value: str) -> None: """Store credential in encrypted store and export to env.""" @@ -561,7 +576,11 @@ def _load_nodes_from_python_agent(agent_path: Path) -> list: sys.modules[spec.name] = module spec.loader.exec_module(module) return getattr(module, "nodes", []) + except (ImportError, OSError) as exc: + logger.debug("Could not load agent module: %s", exc) + return [] except Exception: + logger.warning("Unexpected error loading agent module", exc_info=True) return [] @@ -588,7 +607,11 @@ def _load_nodes_from_json_agent(agent_json: Path) -> list: ) ) return nodes + except (json.JSONDecodeError, KeyError, OSError) as exc: + logger.debug("Could not load JSON agent: %s", exc) + return [] except Exception: + logger.warning("Unexpected error loading JSON agent", exc_info=True) return [] diff --git a/core/framework/graph/safe_eval.py b/core/framework/graph/safe_eval.py index a2aa5a638d..6c32a8da41 100644 --- a/core/framework/graph/safe_eval.py +++ b/core/framework/graph/safe_eval.py @@ -228,10 +228,6 @@ def visit_Call(self, node: ast.Call) -> Any: return func(*args, **keywords) - def visit_Index(self, node: ast.Index) -> Any: - # Python < 3.9 - return self.visit(node.value) - def safe_eval(expr: str, context: dict[str, Any] | None = None) -> Any: """ diff --git a/core/framework/observability/logging.py b/core/framework/observability/logging.py index 86e0a84122..efaf9e2db4 100644 --- a/core/framework/observability/logging.py +++ b/core/framework/observability/logging.py @@ -30,6 +30,8 @@ # ContextVar is thread-safe and async-safe - perfect for concurrent agent execution trace_context: ContextVar[dict[str, Any] | None] = ContextVar("trace_context", default=None) +_STANDARD_LOG_RECORD_FIELDS = set(logging.makeLogRecord({}).__dict__) + # ANSI escape code pattern (matches \033[...m or \x1b[...m) ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m|\033\[[0-9;]*m") @@ -92,6 +94,14 @@ def format(self, record: logging.LogRecord) -> str: if model is not None: log_entry["model"] = model + # Preserve arbitrary structured fields passed via ``extra=...``. + for key, value in record.__dict__.items(): + if key in _STANDARD_LOG_RECORD_FIELDS or key.startswith("_"): + continue + if key in log_entry: + continue + log_entry[key] = value + # Add exception info if present (strip ANSI codes from exception text too) if record.exc_info: exception_text = self.formatException(record.exc_info) diff --git a/core/framework/runner/__init__.py b/core/framework/runner/__init__.py index a3e4cac458..053a0ca46b 100644 --- a/core/framework/runner/__init__.py +++ b/core/framework/runner/__init__.py @@ -1,5 +1,6 @@ """Agent Runner - load and run exported agents.""" +from framework.runner.mcp_registry import MCPRegistry from framework.runner.orchestrator import AgentOrchestrator from framework.runner.protocol import ( AgentMessage, @@ -17,6 +18,7 @@ "AgentInfo", "ValidationResult", "ToolRegistry", + "MCPRegistry", "tool", # Multi-agent "AgentOrchestrator", diff --git a/core/framework/runner/cli.py b/core/framework/runner/cli.py index fdbec06f6d..22d2f2216a 100644 --- a/core/framework/runner/cli.py +++ b/core/framework/runner/cli.py @@ -1561,6 +1561,22 @@ def _open_browser(url: str) -> None: pass # Best-effort — don't crash if browser can't open +def _format_subprocess_output(output: str | bytes | None, limit: int = 2000) -> str: + """Return subprocess output as trimmed text safe for console logging.""" + if not output: + return "" + + if isinstance(output, bytes): + text = output.decode(errors="replace") + else: + text = output + + text = text.strip() + if len(text) <= limit: + return text + return text[-limit:] + + def _build_frontend() -> bool: """Build the frontend if source is newer than dist. Returns True if dist exists.""" import subprocess @@ -1596,18 +1612,25 @@ def _build_frontend() -> bool: # Need to build print("Building frontend...") + npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm" try: + # Incremental tsc caches can drift across branch changes and block builds. + for cache_file in frontend_dir.glob("tsconfig*.tsbuildinfo"): + cache_file.unlink(missing_ok=True) + # Ensure deps are installed subprocess.run( - ["npm", "install", "--no-fund", "--no-audit"], + [npm_cmd, "install", "--no-fund", "--no-audit"], encoding="utf-8", + errors="replace", cwd=frontend_dir, check=True, capture_output=True, ) subprocess.run( - ["npm", "run", "build"], + [npm_cmd, "run", "build"], encoding="utf-8", + errors="replace", cwd=frontend_dir, check=True, capture_output=True, @@ -1618,8 +1641,14 @@ def _build_frontend() -> bool: print("Node.js not found — skipping frontend build.") return dist_dir.is_dir() except subprocess.CalledProcessError as exc: - stderr = exc.stderr.decode(errors="replace") if exc.stderr else "" - print(f"Frontend build failed: {stderr[:500]}") + stdout = _format_subprocess_output(exc.stdout) + stderr = _format_subprocess_output(exc.stderr) + cmd = " ".join(exc.cmd) if isinstance(exc.cmd, (list, tuple)) else str(exc.cmd) + details = "\n".join(part for part in [stdout, stderr] if part).strip() + if details: + print(f"Frontend build failed while running {cmd}:\n{details}") + else: + print(f"Frontend build failed while running {cmd} (exit {exc.returncode}).") return dist_dir.is_dir() diff --git a/core/framework/runner/mcp_connection_manager.py b/core/framework/runner/mcp_connection_manager.py index f9038b9b7e..98bb9a2440 100644 --- a/core/framework/runner/mcp_connection_manager.py +++ b/core/framework/runner/mcp_connection_manager.py @@ -2,7 +2,6 @@ import logging import threading -from typing import Any import httpx @@ -10,6 +9,8 @@ logger = logging.getLogger(__name__) +_TRANSITION_TIMEOUT = 30.0 + class MCPConnectionManager: """Process-wide MCP client pool keyed by server name.""" @@ -22,7 +23,6 @@ def __init__(self) -> None: self._refcounts: dict[str, int] = {} self._configs: dict[str, MCPServerConfig] = {} self._pool_lock = threading.Lock() - # Transition events keep callers from racing a connect/reconnect/disconnect. self._transitions: dict[str, threading.Event] = {} @classmethod @@ -38,6 +38,11 @@ def get_instance(cls) -> "MCPConnectionManager": def _is_connected(client: MCPClient | None) -> bool: return bool(client and getattr(client, "_connected", False)) + def has_connection(self, server_name: str) -> bool: + """Return True when a live pooled connection exists for ``server_name``.""" + with self._pool_lock: + return self._is_connected(self._pool.get(server_name)) + def acquire(self, config: MCPServerConfig) -> MCPClient: """Get or create a shared connection and increment its refcount.""" server_name = config.name @@ -67,13 +72,29 @@ def acquire(self, config: MCPServerConfig) -> MCPClient: should_connect = True if not should_connect: - transition_event.wait() + if not transition_event.wait(timeout=_TRANSITION_TIMEOUT): + logger.warning( + "Timed out waiting for transition on MCP server '%s', " + "forcing cleanup and retrying", + server_name, + ) + with self._pool_lock: + stuck = self._transitions.get(server_name) + if stuck is transition_event: + self._transitions.pop(server_name, None) + transition_event.set() continue + logger.info("Connecting to MCP server '%s'", server_name) client = MCPClient(config) try: client.connect() except Exception: + logger.warning( + "Failed to connect to MCP server '%s'", + server_name, + exc_info=True, + ) with self._pool_lock: current = self._transitions.get(server_name) if current is transition_event: @@ -94,9 +115,21 @@ def acquire(self, config: MCPServerConfig) -> MCPClient: self._configs[server_name] = config self._transitions.pop(server_name, None) transition_event.set() + logger.info( + "Connected to MCP server '%s' (refcount=1)", + server_name, + ) return client - client.disconnect() + # Lost the transition race, clean up and retry + try: + client.disconnect() + except Exception: + logger.debug( + "Error disconnecting stale client for '%s'", + server_name, + exc_info=True, + ) def release(self, server_name: str) -> None: """Decrement refcount and disconnect when the last user releases.""" @@ -113,21 +146,46 @@ def release(self, server_name: str) -> None: return if refcount > 1: self._refcounts[server_name] = refcount - 1 + logger.debug( + "Released MCP server '%s' (refcount=%d)", + server_name, + refcount - 1, + ) return disconnect_client = self._pool.pop(server_name, None) self._refcounts.pop(server_name, None) + self._configs.pop(server_name, None) transition_event = threading.Event() self._transitions[server_name] = transition_event should_disconnect = True if not should_disconnect: - transition_event.wait() + if not transition_event.wait(timeout=_TRANSITION_TIMEOUT): + logger.warning( + "Timed out waiting for transition on '%s' during release, forcing cleanup", + server_name, + ) + with self._pool_lock: + stuck = self._transitions.get(server_name) + if stuck is transition_event: + self._transitions.pop(server_name, None) + transition_event.set() continue try: if disconnect_client is not None: disconnect_client.disconnect() + logger.info( + "Disconnected MCP server '%s' (last reference released)", + server_name, + ) + except Exception: + logger.warning( + "Error disconnecting MCP server '%s' during release", + server_name, + exc_info=True, + ) finally: with self._pool_lock: current = self._transitions.get(server_name) @@ -146,34 +204,60 @@ def health_check(self, server_name: str) -> bool: config = self._configs.get(server_name) break - transition_event.wait() + if not transition_event.wait(timeout=_TRANSITION_TIMEOUT): + logger.warning( + "Timed out waiting for transition on '%s' during health check", + server_name, + ) + return False if client is None or config is None: return False try: - if config.transport == "stdio": - client.list_tools() - return True - - if not config.url: - return False - - client_kwargs: dict[str, Any] = { - "base_url": config.url, - "headers": config.headers, - "timeout": 5.0, - } - if config.transport == "unix": - if not config.socket_path: + match config.transport: + case "stdio": + client.list_tools() + return True + case "http": + if not config.url: + return False + with httpx.Client( + base_url=config.url, + headers=config.headers, + timeout=5.0, + ) as http_client: + response = http_client.get("/health") + response.raise_for_status() + return True + case "sse": + client.list_tools() + return True + case "unix": + if not config.socket_path: + return False + with httpx.Client( + base_url=config.url or "http://localhost", + headers=config.headers, + timeout=5.0, + transport=httpx.HTTPTransport(uds=config.socket_path), + ) as http_client: + response = http_client.get("/health") + response.raise_for_status() + return True + case _: + logger.warning( + "Unknown transport '%s' for health check on '%s'", + config.transport, + server_name, + ) return False - client_kwargs["transport"] = httpx.HTTPTransport(uds=config.socket_path) - - with httpx.Client(**client_kwargs) as http_client: - response = http_client.get("/health") - response.raise_for_status() - return True except Exception: + logger.debug( + "Health check failed for MCP server '%s'", + server_name, + exc_info=True, + ) return False def reconnect(self, server_name: str) -> MCPClient: @@ -189,16 +273,34 @@ def reconnect(self, server_name: str) -> MCPClient: if config is None: raise KeyError(f"Unknown MCP server: {server_name}") old_client = self._pool.get(server_name) - refcount = self._refcounts.get(server_name, 0) transition_event = threading.Event() self._transitions[server_name] = transition_event break - transition_event.wait() + if not transition_event.wait(timeout=_TRANSITION_TIMEOUT): + logger.warning( + "Timed out waiting for transition on '%s' during reconnect, forcing cleanup", + server_name, + ) + with self._pool_lock: + stuck = self._transitions.get(server_name) + if stuck is transition_event: + self._transitions.pop(server_name, None) + transition_event.set() + # Disconnect old client safely if old_client is not None: - old_client.disconnect() + try: + old_client.disconnect() + logger.info("Disconnected old client for '%s'", server_name) + except Exception: + logger.warning( + "Error disconnecting old client for '%s' during reconnect", + server_name, + exc_info=True, + ) + logger.info("Reconnecting MCP server '%s'", server_name) new_client = MCPClient(config) try: new_client.connect() @@ -214,13 +316,50 @@ def reconnect(self, server_name: str) -> MCPClient: with self._pool_lock: current = self._transitions.get(server_name) if current is transition_event: + current_refcount = self._refcounts.get(server_name, 0) + if current_refcount <= 0: + # All holders released during reconnect. Discard the + # new client instead of creating a phantom reference. + # Caller should acquire() fresh if needed. + self._transitions.pop(server_name, None) + transition_event.set() + logger.info( + "Reconnected MCP server '%s' but refcount dropped to 0, " + "discarding new client", + server_name, + ) + try: + new_client.disconnect() + except Exception: + logger.debug( + "Error disconnecting discarded client for '%s'", + server_name, + exc_info=True, + ) + raise KeyError( + f"MCP server '{server_name}' was fully released during reconnect" + ) + self._pool[server_name] = new_client - self._refcounts[server_name] = max(refcount, 1) + self._configs[server_name] = config + self._refcounts[server_name] = current_refcount self._transitions.pop(server_name, None) transition_event.set() + logger.info( + "Reconnected MCP server '%s' (refcount=%d)", + server_name, + current_refcount, + ) return new_client - new_client.disconnect() + try: + new_client.disconnect() + except Exception: + logger.debug( + "Error disconnecting stale client for '%s' after reconnect race", + server_name, + exc_info=True, + ) return self.acquire(config) def cleanup_all(self) -> None: @@ -238,14 +377,29 @@ def cleanup_all(self) -> None: self._configs.clear() break - for event in pending: - event.wait() + all_resolved = all(event.wait(timeout=_TRANSITION_TIMEOUT) for event in pending) + if not all_resolved: + logger.warning( + "Timed out waiting for pending transitions during cleanup, " + "forcing cleanup of stuck transitions", + ) + with self._pool_lock: + for sn, evt in list(self._transitions.items()): + if not evt.is_set(): + self._transitions.pop(sn, None) + evt.set() - for _server_name, client in clients: + logger.info("Cleaning up %d pooled MCP connections", len(clients)) + for server_name, client in clients: try: client.disconnect() + logger.debug("Disconnected MCP server '%s' during cleanup", server_name) except Exception: - pass + logger.warning( + "Error disconnecting MCP server '%s' during cleanup", + server_name, + exc_info=True, + ) with self._pool_lock: for server_name, event in cleanup_events.items(): diff --git a/core/framework/runner/mcp_registry.py b/core/framework/runner/mcp_registry.py new file mode 100644 index 0000000000..2057a1e12a --- /dev/null +++ b/core/framework/runner/mcp_registry.py @@ -0,0 +1,815 @@ +"""MCP Server Registry: local state management for installed MCP servers.""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +import tomllib +from datetime import UTC, datetime +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any, Literal + +import httpx + +from framework.runner.mcp_client import MCPClient, MCPServerConfig +from framework.runner.mcp_connection_manager import MCPConnectionManager + +logger = logging.getLogger(__name__) + +DEFAULT_INDEX_URL = ( + "https://raw.githubusercontent.com/aden-hive/hive-mcp-registry/main/registry_index.json" +) +DEFAULT_REFRESH_INTERVAL_HOURS = 24 +_LAST_FETCHED_FILENAME = "last_fetched" +_LEGACY_LAST_FETCHED_FILENAME = "last_fetched.json" + +_DEFAULT_CONFIG = { + "index_url": DEFAULT_INDEX_URL, + "refresh_interval_hours": DEFAULT_REFRESH_INTERVAL_HOURS, +} + + +class MCPRegistry: + """Manages local MCP server state in ~/.hive/mcp_registry/.""" + + def __init__(self, base_path: Path | None = None): + self._base = base_path or Path.home() / ".hive" / "mcp_registry" + self._installed_path = self._base / "installed.json" + self._config_path = self._base / "config.json" + self._cache_dir = self._base / "cache" + + # ── Initialization ────────────────────────────────────────────── + + def initialize(self) -> None: + """Create directory structure and default files if missing.""" + self._base.mkdir(parents=True, exist_ok=True) + self._cache_dir.mkdir(parents=True, exist_ok=True) + + if not self._config_path.exists(): + self._write_json(self._config_path, _DEFAULT_CONFIG) + + if not self._installed_path.exists(): + self._write_json(self._installed_path, {"servers": {}}) + + # ── Internal I/O ──────────────────────────────────────────────── + + def _read_installed(self) -> dict: + """Read installed.json, initializing if needed.""" + if not self._installed_path.exists(): + self.initialize() + return json.loads(self._installed_path.read_text(encoding="utf-8")) + + def _write_installed(self, data: dict) -> None: + """Write installed.json.""" + self._write_json(self._installed_path, data) + + def _read_config(self) -> dict: + """Read config.json.""" + if not self._config_path.exists(): + self.initialize() + return json.loads(self._config_path.read_text(encoding="utf-8")) + + def _read_cached_index(self) -> dict: + """Read cached registry_index.json.""" + index_path = self._cache_dir / "registry_index.json" + if not index_path.exists(): + return {"servers": {}} + return json.loads(index_path.read_text(encoding="utf-8")) + + def _get_effective_manifest( + self, + name: str, + entry: dict, + cached_index: dict | None = None, + ) -> dict: + """Return the manifest currently in effect for an installed entry.""" + manifest = entry.get("manifest", {}) + if entry.get("source") != "registry": + return manifest + + index = cached_index or self._read_cached_index() + cached_manifest = index.get("servers", {}).get(name) + if cached_manifest is not None: + return cached_manifest + + # Fall back to persisted manifest data when the cache is unavailable. + if isinstance(manifest, dict) and manifest: + return manifest + return {} + + @staticmethod + def _write_json(path: Path, data: dict) -> None: + """Write JSON to file atomically (write to temp, fsync, rename).""" + content = json.dumps(data, indent=2) + "\n" + fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + # ── add_local ─────────────────────────────────────────────────── + + def add_local( + self, + name: str, + transport: str | None = None, + manifest: dict | None = None, + url: str | None = None, + command: str | None = None, + args: list[str] | None = None, + env: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + cwd: str | None = None, + socket_path: str | None = None, + description: str = "", + ) -> dict: + """Register a local/running MCP server. + + Can be called with an inline manifest dict, or with individual + transport/url/command params that build a manifest automatically. + """ + data = self._read_installed() + if name in data["servers"]: + raise ValueError(f"Server '{name}' already exists. Use remove first.") + + if manifest is not None: + # Inline manifest provided directly + manifest = {**manifest, "name": name} + transport_config = manifest.get("transport", {}) + transport = transport or transport_config.get("default", "stdio") + if "transport" not in manifest: + manifest["transport"] = {"supported": [transport], "default": transport} + else: + # Build manifest from individual params + if not transport: + raise ValueError("transport is required when manifest is not provided") + manifest = { + "name": name, + "description": description, + "transport": {"supported": [transport], "default": transport}, + } + match transport: + case "http": + if not url: + raise ValueError("url is required for http transport") + manifest["http"] = {"url": url, "headers": headers or {}} + case "stdio": + if not command: + raise ValueError("command is required for stdio transport") + manifest["stdio"] = { + "command": command, + "args": args or [], + "env": env or {}, + "cwd": cwd, + } + case "unix": + if not socket_path: + raise ValueError("socket_path is required for unix transport") + manifest["unix"] = {"socket_path": socket_path} + manifest["http"] = {"url": url or "http://localhost"} + case "sse": + if not url: + raise ValueError("url is required for sse transport") + manifest["sse"] = {"url": url} + case _: + raise ValueError(f"Unsupported transport: {transport}") + + entry = self._make_entry( + source="local", + manifest=manifest, + transport=transport, + installed_by="hive mcp add", + ) + + data["servers"][name] = entry + self._write_installed(data) + logger.info("Registered local MCP server '%s' (%s)", name, transport) + return entry + + # ── install ───────────────────────────────────────────────────── + + def install(self, name: str, transport: str | None = None, version: str | None = None) -> dict: + """Install a server from the cached remote registry index.""" + data = self._read_installed() + if name in data["servers"]: + raise ValueError(f"Server '{name}' already exists. Remove it first or use update.") + + index = self._read_cached_index() + manifest = index.get("servers", {}).get(name) + if manifest is None: + raise ValueError( + f"Server '{name}' not found in registry index. " + "Run 'hive mcp update' to refresh the index." + ) + + # Validate version if specified + if version is not None: + index_version = manifest.get("version") + if index_version is None: + raise ValueError(f"Cannot pin version for '{name}': manifest has no version field.") + if index_version != version: + raise ValueError( + f"Version mismatch for '{name}': requested {version}, " + f"index has {index_version}. " + "Run 'hive mcp update' to refresh the index." + ) + + transport_config = manifest.get("transport", {}) + supported = transport_config.get("supported", []) + if transport is not None: + if supported and transport not in supported: + raise ValueError( + f"Transport '{transport}' not supported by '{name}'. Supported: {supported}" + ) + resolved_transport = transport + else: + resolved_transport = transport_config.get("default", "stdio") + + entry = self._make_entry( + source="registry", + manifest=self._make_registry_manifest_snapshot(name, manifest), + transport=resolved_transport, + installed_by="hive mcp install", + pinned=version is not None, + auto_update=version is None, + resolved_package_version=manifest.get("version"), + ) + + data["servers"][name] = entry + self._write_installed(data) + logger.info( + "Installed MCP server '%s' v%s from registry", + name, + entry["manifest_version"], + ) + return entry + + # ── remove / enable / disable ─────────────────────────────────── + + def remove(self, name: str) -> None: + """Remove a server from the registry.""" + data = self._read_installed() + if name not in data["servers"]: + raise ValueError(f"Server '{name}' is not installed.") + del data["servers"][name] + self._write_installed(data) + logger.info("Removed MCP server '%s'", name) + + def enable(self, name: str) -> None: + """Enable a disabled server.""" + self._set_enabled(name, enabled=True) + + def disable(self, name: str) -> None: + """Disable a server without removing it.""" + self._set_enabled(name, enabled=False) + + def _set_enabled(self, name: str, *, enabled: bool) -> None: + data = self._read_installed() + if name not in data["servers"]: + raise ValueError(f"Server '{name}' is not installed.") + data["servers"][name]["enabled"] = enabled + self._write_installed(data) + logger.info("%s MCP server '%s'", "Enabled" if enabled else "Disabled", name) + + # ── list / get ────────────────────────────────────────────────── + + def list_installed(self) -> list[dict]: + """Return all installed servers as a list of dicts with name included.""" + data = self._read_installed() + return [{"name": name, **entry} for name, entry in data["servers"].items()] + + def get_server(self, name: str) -> dict | None: + """Get a single installed server entry by name, or None if not found.""" + data = self._read_installed() + entry = data["servers"].get(name) + if entry is None: + return None + return {"name": name, **entry} + + def list_available(self) -> list[dict]: + """List all servers from cached remote index.""" + index = self._read_cached_index() + return [{"name": name, **m} for name, m in index.get("servers", {}).items()] + + # ── set_override ──────────────────────────────────────────────── + + def set_override( + self, + name: str, + key: str, + value: str, + override_type: Literal["env", "headers"] = "env", + ) -> None: + """Set an env or header override for a server.""" + data = self._read_installed() + if name not in data["servers"]: + raise ValueError(f"Server '{name}' is not installed.") + if override_type not in ("env", "headers"): + raise ValueError(f"Invalid override type: {override_type}") + data["servers"][name]["overrides"][override_type][key] = value + self._write_installed(data) + logger.info("Set %s override %s for MCP server '%s'", override_type, key, name) + + # ── search ────────────────────────────────────────────────────── + + def search(self, query: str) -> list[dict]: + """Search registry index by name, tag, description, or tool name.""" + query_lower = query.lower() + index = self._read_cached_index() + matches = [] + + for name, manifest in index.get("servers", {}).items(): + if self._matches_query(name, manifest, query_lower): + matches.append({"name": name, **manifest}) + + return matches + + @staticmethod + def _matches_query(name: str, manifest: dict, query: str) -> bool: + """Check if a manifest matches a search query.""" + if query in name.lower(): + return True + + description = manifest.get("description", "") + if query in description.lower(): + return True + + for tag in manifest.get("tags", []): + if query in tag.lower(): + return True + + for tool in manifest.get("tools", []): + tool_name = tool.get("name", "") if isinstance(tool, dict) else str(tool) + if query in tool_name.lower(): + return True + + return False + + # ── update_index ──────────────────────────────────────────────── + + def is_index_stale(self) -> bool: + """Check if the cached registry index needs refreshing.""" + last_fetched_path = self._cache_dir / _LAST_FETCHED_FILENAME + legacy_path = self._cache_dir / _LEGACY_LAST_FETCHED_FILENAME + if not last_fetched_path.exists() and not legacy_path.exists(): + return True + + try: + path = last_fetched_path if last_fetched_path.exists() else legacy_path + data = json.loads(path.read_text(encoding="utf-8")) + last_fetched = datetime.fromisoformat(data["timestamp"]) + config = self._read_config() + interval_hours = config.get("refresh_interval_hours", DEFAULT_REFRESH_INTERVAL_HOURS) + age_hours = (datetime.now(UTC) - last_fetched).total_seconds() / 3600 + return age_hours >= interval_hours + except (KeyError, ValueError, OSError): + return True + + def update_index(self) -> int: + """Fetch the latest registry index from remote and cache it. + + Returns the number of servers in the index. + """ + config = self._read_config() + url = config.get("index_url", DEFAULT_INDEX_URL) + + response = httpx.get(url, timeout=10.0) + response.raise_for_status() + index = response.json() + + self._write_json(self._cache_dir / "registry_index.json", index) + # Write last_fetched atomically too + self._write_json( + self._cache_dir / _LAST_FETCHED_FILENAME, + {"timestamp": datetime.now(UTC).isoformat()}, + ) + + server_count = len(index.get("servers", {})) + logger.info("Updated registry index: %d servers available", server_count) + return server_count + + # ── load_agent_selection ──────────────────────────────────────── + + def load_agent_selection(self, agent_path: Path) -> list[dict[str, Any]]: + """Load mcp_registry.json from an agent directory and resolve servers. + + Returns list of plain dicts compatible with ToolRegistry.register_mcp_server(). + """ + registry_json_path = agent_path / "mcp_registry.json" + if not registry_json_path.exists(): + return [] + + selection = json.loads(registry_json_path.read_text(encoding="utf-8")) + + # Validate types at the JSON boundary. Bad fields are dropped with a + # warning so the agent still starts (graceful degradation). + expected_types: dict[str, type] = { + "include": list, + "tags": list, + "exclude": list, + "profile": str, + "max_tools": int, + "versions": dict, + } + validated: dict[str, Any] = {} + for field, expected in expected_types.items(): + value = selection.get(field) + if value is None: + continue + if not isinstance(value, expected): + logger.warning( + "mcp_registry.json: '%s' must be %s, got %s; ignoring", + field, + expected.__name__, + type(value).__name__, + ) + continue + validated[field] = value + + configs = self.resolve_for_agent( + include=validated.get("include"), + tags=validated.get("tags"), + exclude=validated.get("exclude"), + profile=validated.get("profile"), + max_tools=validated.get("max_tools"), + versions=validated.get("versions"), + ) + return [self._server_config_to_dict(c) for c in configs] + + # ── resolve_for_agent ─────────────────────────────────────────── + + def resolve_for_agent( + self, + include: list[str] | None = None, + tags: list[str] | None = None, + exclude: list[str] | None = None, + profile: str | None = None, + max_tools: int | None = None, + versions: dict[str, str] | None = None, + ) -> list[MCPServerConfig]: + """Resolve installed servers matching agent selection criteria. + + Selection precedence per PRD section 7.2: + 1. profile expands to server names (union with include + tags) + 2. include adds explicit servers + 3. tags adds servers whose tags overlap + 4. exclude removes (always wins) + 5. Load order: include-order first, then alphabetical for tag/profile matches + + Returns list of MCPServerConfig objects ready for ToolRegistry. + """ + data = self._read_installed() + servers = data.get("servers", {}) + cached_index = self._read_cached_index() + exclude_set = set(exclude or []) + + # Phase 1: collect profile-matched servers (alphabetical) + profile_matched: list[str] = [] + if profile: + for name, entry in sorted(servers.items()): + if name in exclude_set: + continue + if profile == "all": + profile_matched.append(name) + else: + manifest = self._get_effective_manifest(name, entry, cached_index) + profiles = manifest.get("hive", {}).get("profiles", []) + if profile in profiles: + profile_matched.append(name) + + # Phase 2: collect tag-matched servers (alphabetical) + tag_matched: list[str] = [] + if tags: + tag_set = set(tags) + for name, entry in sorted(servers.items()): + if name in exclude_set: + continue + manifest = self._get_effective_manifest(name, entry, cached_index) + server_tags = set(manifest.get("tags", [])) + if tag_set & server_tags: + tag_matched.append(name) + + # Phase 3: build final ordered list + # include-order first, then alphabetical for profile/tag matches + selected: list[str] = [] + seen: set[str] = set() + + for name in include or []: + if name not in seen and name not in exclude_set: + selected.append(name) + seen.add(name) + + for name in profile_matched: + if name not in seen: + selected.append(name) + seen.add(name) + + for name in tag_matched: + if name not in seen: + selected.append(name) + seen.add(name) + + # Build configs, tracking aggregate tool count for max_tools cap (FR-56) + configs: list[MCPServerConfig] = [] + total_tools = 0 + for name in selected: + entry = servers.get(name) + if entry is None: + logger.warning( + "Server '%s' requested but not installed. Run: hive mcp install %s", + name, + name, + ) + continue + if not entry.get("enabled", True): + continue + + manifest = self._get_effective_manifest(name, entry, cached_index) + + # Check version pin (VC-6) + if versions and name in versions: + installed_version = entry.get("manifest_version", "0.0.0") + pinned_version = versions[name] + if installed_version != pinned_version: + logger.warning( + "Server '%s' version mismatch: installed=%s, pinned=%s. " + "Run: hive mcp update %s", + name, + installed_version, + pinned_version, + name, + ) + continue + + # Check tool count cap before adding (FR-56) + manifest_tools = manifest.get("tools", []) + server_tool_count = len(manifest_tools) + if max_tools is not None and server_tool_count == 0: + logger.debug( + "Server '%s' has no declared tools in manifest, skipping max_tools check", + name, + ) + elif max_tools is not None and total_tools + server_tool_count > max_tools: + logger.info( + "Skipping server '%s' (%d tools): would exceed max_tools=%d", + name, + server_tool_count, + max_tools, + ) + continue + + config = self._manifest_to_server_config( + name, + manifest, + entry.get("overrides", {}), + transport_override=entry.get("transport"), + ) + if config is not None: + configs.append(config) + total_tools += server_tool_count + + return configs + + def _manifest_to_server_config( + self, + name: str, + manifest: dict, + overrides: dict | None = None, + transport_override: str | None = None, + ) -> MCPServerConfig | None: + """Convert a manifest and overrides to MCPServerConfig.""" + overrides = overrides or {} + transport_config = manifest.get("transport", {}) + transport = transport_override or transport_config.get("default", "stdio") + description = manifest.get("description", "") + + match transport: + case "stdio": + stdio_config = manifest.get("stdio", {}) + merged_env = { + **stdio_config.get("env", {}), + **overrides.get("env", {}), + } + return MCPServerConfig( + name=name, + transport="stdio", + command=stdio_config.get("command"), + args=stdio_config.get("args", []), + env=merged_env, + cwd=stdio_config.get("cwd"), + description=description, + ) + case "http": + http_config = manifest.get("http", {}) + url = http_config.get("url", "") + merged_headers = { + **http_config.get("headers", {}), + **overrides.get("headers", {}), + } + return MCPServerConfig( + name=name, + transport="http", + url=url, + headers=merged_headers, + description=description, + ) + case "unix": + unix_config = manifest.get("unix", {}) + http_config = manifest.get("http", {}) + merged_headers = { + **http_config.get("headers", {}), + **overrides.get("headers", {}), + } + return MCPServerConfig( + name=name, + transport="unix", + socket_path=unix_config.get("socket_path"), + url=http_config.get("url") or "http://localhost", + headers=merged_headers, + description=description, + ) + case "sse": + sse_config = manifest.get("sse", {}) + merged_headers = { + **sse_config.get("headers", {}), + **overrides.get("headers", {}), + } + return MCPServerConfig( + name=name, + transport="sse", + url=sse_config.get("url", ""), + headers=merged_headers, + description=description, + ) + case _: + logger.warning( + "Unsupported transport '%s' for server '%s'", + transport, + name, + ) + return None + + @staticmethod + def _server_config_to_dict(config: MCPServerConfig) -> dict[str, Any]: + """Convert MCPServerConfig to plain dict for ToolRegistry.register_mcp_server().""" + return { + "name": config.name, + "transport": config.transport, + "command": config.command, + "args": config.args, + "env": config.env, + "cwd": config.cwd, + "url": config.url, + "headers": config.headers, + "socket_path": config.socket_path, + "description": config.description, + } + + # ── run_health_check ──────────────────────────────────────────── + + def health_check(self, name: str | None = None) -> dict | dict[str, dict]: + """Check health of installed server(s). Updates telemetry fields. + + If name is None, checks all installed servers and returns + a dict mapping server names to their health results. + + """ + if name is None: + results = {} + for server in self.list_installed(): + results[server["name"]] = self.health_check(server["name"]) + return results + + data = self._read_installed() + if name not in data["servers"]: + raise ValueError(f"Server '{name}' is not installed.") + + entry = data["servers"][name] + manifest = self._get_effective_manifest(name, entry) + config = self._manifest_to_server_config( + name, + manifest, + entry.get("overrides", {}), + transport_override=entry.get("transport"), + ) + now = datetime.now(UTC).isoformat() + + result: dict[str, Any] = { + "name": name, + "status": "unknown", + "tools": 0, + "error": None, + } + + if config is None: + transport = entry.get("transport", "unknown") + result["status"] = "unhealthy" + result["error"] = f"Unsupported transport '{transport}'" + entry["last_health_status"] = "unhealthy" + entry["last_error"] = result["error"] + entry["last_health_check_at"] = now + self._write_installed(data) + return result + + manager = MCPConnectionManager.get_instance() + + try: + if manager.has_connection(name): + is_healthy = manager.health_check(name) + if not is_healthy: + raise RuntimeError("Shared MCP connection health check failed") + pooled_client = manager.acquire(config) + try: + tools = pooled_client.list_tools() + finally: + manager.release(name) + else: + with MCPClient(config) as client: + tools = client.list_tools() + + result["status"] = "healthy" + result["tools"] = len(tools) + entry["last_health_status"] = "healthy" + entry["last_error"] = None + entry["last_validated_with_hive_version"] = self._get_hive_version() + except Exception as exc: + result["status"] = "unhealthy" + result["error"] = str(exc) + entry["last_health_status"] = "unhealthy" + entry["last_error"] = str(exc) + + entry["last_health_check_at"] = now + self._write_installed(data) + return result + + def run_health_check(self, name: str | None = None) -> dict | dict[str, dict]: + """Backward-compatible wrapper for the public health_check API.""" + return self.health_check(name) + + @staticmethod + def _get_hive_version() -> str: + """Get the current Hive version.""" + try: + return version("framework") + except PackageNotFoundError: + project_toml = Path(__file__).resolve().parents[2] / "pyproject.toml" + if not project_toml.exists(): + return "unknown" + try: + with project_toml.open("rb") as f: + data = tomllib.load(f) + return data.get("project", {}).get("version", "unknown") + except (tomllib.TOMLDecodeError, OSError): + return "unknown" + + # ── helpers ────────────────────────────────────────────────────── + + @staticmethod + def _make_entry( + *, + source: str, + manifest: dict, + transport: str, + installed_by: str, + pinned: bool = False, + auto_update: bool = False, + resolved_package_version: str | None = None, + ) -> dict: + """Build a standard installed server entry.""" + now = datetime.now(UTC).isoformat() + return { + "source": source, + "manifest_version": manifest.get("version", "0.0.0"), + "manifest": manifest, + "installed_at": now, + "installed_by": installed_by, + "transport": transport, + "enabled": True, + "pinned": pinned, + "auto_update": auto_update, + "resolved_package_version": resolved_package_version, + "overrides": {"env": {}, "headers": {}}, + "last_health_check_at": None, + "last_health_status": None, + "last_error": None, + "last_used_at": None, + "last_validated_with_hive_version": None, + } + + @staticmethod + def _make_registry_manifest_snapshot(name: str, manifest: dict) -> dict[str, Any]: + """Persist a full manifest snapshot for registry-installed servers.""" + manifest_snapshot = dict(manifest) + manifest_snapshot["name"] = name + return manifest_snapshot diff --git a/core/framework/runner/mcp_registry_cli.py b/core/framework/runner/mcp_registry_cli.py new file mode 100644 index 0000000000..b84b59dc56 --- /dev/null +++ b/core/framework/runner/mcp_registry_cli.py @@ -0,0 +1,906 @@ +"""CLI commands for MCP server registry management. + +Commands: + hive mcp install Install a server from the registry + hive mcp add Register a local/running MCP server + hive mcp remove Remove an installed server + hive mcp enable Enable a server + hive mcp disable Disable a server + hive mcp list List installed servers + hive mcp info Show server details + hive mcp config Set env/header overrides + hive mcp search Search the registry index + hive mcp health [name] Check server health + hive mcp update Refresh index and update installed servers + hive mcp update Update a single installed server +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +# ── Shared helpers ────────────────────────────────────────────────── + + +def _get_registry(base_path: Path | None = None): + """Initialize and return an MCPRegistry instance.""" + from framework.runner.mcp_registry import MCPRegistry + + registry = MCPRegistry(base_path=base_path) + registry.initialize() + return registry + + +def _ensure_index_available(registry) -> bool: + """Ensure the registry index is cached locally. + + If no index exists or the cache is stale, fetches a fresh copy. + Returns True if a usable index exists, False otherwise. + + Semantics: + - Stale cache + refresh fails -> warn and continue with stale cache (True) + - No cache + refresh fails -> hard fail (False) + """ + import httpx + + cache_exists = (registry._cache_dir / "registry_index.json").exists() + + if registry.is_index_stale(): + print("Updating registry index...", file=sys.stderr) + try: + count = registry.update_index() + print(f"Registry index updated ({count} servers available).", file=sys.stderr) + return True + except (httpx.HTTPError, OSError) as exc: + if cache_exists: + print( + f"Warning: failed to update registry index: {exc}\nUsing cached index.", + file=sys.stderr, + ) + return True + print( + f"Error: no registry index available and refresh failed: {exc}\n" + "Check your network connection and try: hive mcp update", + file=sys.stderr, + ) + return False + + return cache_exists + + +_SECURITY_NOTICE = ( + "Registry servers run code on your machine. Only install servers you trust.\n" + "Learn more: https://github.com/aden-hive/hive-mcp-registry" +) +_NOTICE_SENTINEL = ".security_notice_shown" + + +def _print_security_notice_if_first_use(registry_base: Path) -> None: + """Print a one-time security notice on first registry install. + + Only prints the notice. Call _mark_security_notice_shown() after + a successful install to persist the sentinel. + """ + sentinel = registry_base / _NOTICE_SENTINEL + if sentinel.exists(): + return + print(f"\n {_SECURITY_NOTICE}\n", file=sys.stderr) + + +def _mark_security_notice_shown(registry_base: Path) -> None: + """Persist the security notice sentinel after a successful install.""" + sentinel = registry_base / _NOTICE_SENTINEL + try: + sentinel.touch() + except OSError: + pass + + +def _prompt_for_missing_credentials( + registry, + name: str, + manifest: dict, +) -> None: + """Prompt for required credentials not already set in env or overrides.""" + credentials = manifest.get("credentials", []) + if not credentials: + return + + server = registry.get_server(name) + existing_overrides = server.get("overrides", {}).get("env", {}) if server else {} + + prompted = False + for cred in credentials: + if not isinstance(cred, dict): + continue + env_var = cred.get("env_var", "") + if not env_var: + continue + required = cred.get("required", False) + if not required: + continue + + # Skip if already in environment or overrides + if os.environ.get(env_var) or existing_overrides.get(env_var): + continue + + if not prompted: + print(f"\n{name} requires credentials:", file=sys.stderr) + prompted = True + + description = cred.get("description", env_var) + help_url = cred.get("help_url", "") + help_hint = f" (get one at {help_url})" if help_url else "" + + try: + value = input(f" {description}{help_hint}\n {env_var}: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nSkipped credential prompting.", file=sys.stderr) + return + + if value: + registry.set_override(name, env_var, value, override_type="env") + + +def _parse_key_value_pairs(values: list[str]) -> dict[str, str]: + """Parse KEY=VAL pairs from CLI args. Raises ValueError on bad format.""" + result = {} + for item in values: + if "=" not in item: + raise ValueError( + f"Invalid format: '{item}'. Expected KEY=VALUE.\n" + f"Example: --set JIRA_API_TOKEN=abc123" + ) + key, _, value = item.partition("=") + if not key: + raise ValueError(f"Invalid format: '{item}'. Key cannot be empty.") + result[key] = value + return result + + +def _find_agents_using_server(registry, name: str) -> list[str]: + """Scan agent directories for mcp_registry.json files that would load a server. + + Uses MCPRegistry.load_agent_selection() to resolve actual selection logic + so results stay consistent with runtime behavior. + """ + agent_dirs: list[Path] = [] + # parents: [0]=runner, [1]=framework, [2]=core, [3]=hive (project root) + # NOTE: This path arithmetic assumes running from the source tree layout. + # It will not resolve correctly if installed via pip into site-packages. + project_root = Path(__file__).resolve().parents[3] + core_dir = Path(__file__).resolve().parents[2] + + candidates = [ + project_root / "exports", + core_dir / "exports", + core_dir / "framework" / "agents", + ] + for candidate in candidates: + if candidate.is_dir(): + for child in candidate.iterdir(): + if child.is_dir(): + agent_dirs.append(child) + + matches = [] + for agent_dir in agent_dirs: + registry_json = agent_dir / "mcp_registry.json" + if not registry_json.exists(): + continue + try: + configs = registry.load_agent_selection(agent_dir) + resolved_names = {c["name"] for c in configs} + if name in resolved_names: + matches.append(str(agent_dir)) + except Exception: + continue + + return matches + + +def _render_installed_table(entries: list[dict]) -> None: + """Render installed servers as a formatted table.""" + if not entries: + print("No servers installed.") + print("Run 'hive mcp install ' or 'hive mcp add' to get started.") + return + + # Column widths + name_w = max(len(e["name"]) for e in entries) + name_w = max(name_w, 4) + transport_w = max(len(e.get("transport", "")) for e in entries) + transport_w = max(transport_w, 9) + + header = ( + f" {'NAME':<{name_w}} " + f"{'TRANSPORT':<{transport_w}} " + f"{'ENABLED':<7} " + f"{'HEALTH':<9} " + f"{'TOOLS':<5} " + f"{'TRUST':<10} " + f"{'SOURCE'}" + ) + print(header) + print(" " + "─" * (len(header) - 2)) + + for entry in entries: + enabled = "yes" if entry.get("enabled", True) else "no" + health = entry.get("last_health_status") or "unknown" + health_sym = {"healthy": "✓", "unhealthy": "✗"}.get(health, "●") + source = entry.get("source", "") + manifest = entry.get("manifest", {}) + tools_count = str(len(manifest.get("tools", []))) + trust_tier = manifest.get("status", "") + print( + f" {entry['name']:<{name_w}} " + f"{entry.get('transport', ''):<{transport_w}} " + f"{enabled:<7} " + f"{health_sym} {health:<7} " + f"{tools_count:<5} " + f"{trust_tier:<10} " + f"{source}" + ) + + +def _render_available_table(entries: list[dict]) -> None: + """Render available registry servers as a formatted table.""" + if not entries: + print("No servers in registry index.") + print("Run 'hive mcp update' to refresh the index.") + return + + name_w = max(len(e["name"]) for e in entries) + name_w = max(name_w, 4) + + header = f" {'NAME':<{name_w}} {'VERSION':<9} {'STATUS':<10} DESCRIPTION" + print(header) + print(" " + "─" * (len(header) - 2)) + + for entry in entries: + version = entry.get("version", "") + status = entry.get("status", "community") + desc = entry.get("description", "") + # Truncate long descriptions + if len(desc) > 60: + desc = desc[:57] + "..." + print(f" {entry['name']:<{name_w}} {version:<9} {status:<10} {desc}") + + +def _mask_overrides(overrides: dict) -> dict: + """Replace override values with '' markers. Shared by all output paths.""" + masked: dict[str, dict[str, str]] = {} + if overrides.get("env"): + masked["env"] = dict.fromkeys(overrides["env"], "") + else: + masked["env"] = {} + if overrides.get("headers"): + masked["headers"] = dict.fromkeys(overrides["headers"], "") + else: + masked["headers"] = {} + return masked + + +def _emit_json(data: Any) -> None: + """Print data as formatted JSON.""" + print(json.dumps(data, indent=2, default=str)) + + +# ── Command registration ─────────────────────────────────────────── + + +def register_mcp_commands(subparsers) -> None: + """Register the ``hive mcp`` subcommand group.""" + mcp_parser = subparsers.add_parser("mcp", help="Manage MCP servers") + mcp_sub = mcp_parser.add_subparsers(dest="mcp_command", required=True) + + # ── install ── + install_p = mcp_sub.add_parser("install", help="Install a server from the registry") + install_p.add_argument("name", help="Server name in the registry") + install_p.add_argument( + "--version", dest="version", default=None, help="Pin to a specific version" + ) + install_p.add_argument( + "--transport", default=None, help="Override default transport (stdio, http, unix, sse)" + ) + install_p.set_defaults(func=cmd_mcp_install) + + # ── add ── + add_p = mcp_sub.add_parser("add", help="Register a local/running MCP server") + add_p.add_argument("--name", required=False, help="Server name") + add_p.add_argument( + "--transport", + choices=["stdio", "http", "unix", "sse"], + default=None, + help="Transport type", + ) + add_p.add_argument("--url", default=None, help="Server URL (http, unix, sse)") + add_p.add_argument("--command", default=None, help="Command to run (stdio)") + add_p.add_argument("--args", nargs="*", default=None, help="Command arguments (stdio)") + add_p.add_argument("--socket-path", default=None, help="Unix socket path") + add_p.add_argument("--description", default="", help="Server description") + add_p.add_argument("--from", dest="from_manifest", default=None, help="Path to manifest.json") + add_p.set_defaults(func=cmd_mcp_add) + + # ── remove ── + remove_p = mcp_sub.add_parser("remove", help="Remove an installed server") + remove_p.add_argument("name", help="Server name") + remove_p.set_defaults(func=cmd_mcp_remove) + + # ── enable ── + enable_p = mcp_sub.add_parser("enable", help="Enable a disabled server") + enable_p.add_argument("name", help="Server name") + enable_p.set_defaults(func=cmd_mcp_enable) + + # ── disable ── + disable_p = mcp_sub.add_parser("disable", help="Disable a server without removing it") + disable_p.add_argument("name", help="Server name") + disable_p.set_defaults(func=cmd_mcp_disable) + + # ── list ── + list_p = mcp_sub.add_parser("list", help="List servers") + list_p.add_argument( + "--available", action="store_true", help="Show available servers from registry" + ) + list_p.add_argument("--json", dest="output_json", action="store_true", help="Output as JSON") + list_p.set_defaults(func=cmd_mcp_list) + + # ── info ── + info_p = mcp_sub.add_parser("info", help="Show server details") + info_p.add_argument("name", help="Server name") + info_p.add_argument("--json", dest="output_json", action="store_true", help="Output as JSON") + info_p.set_defaults(func=cmd_mcp_info) + + # ── config ── + config_p = mcp_sub.add_parser("config", help="Set server configuration overrides") + config_p.add_argument("name", help="Server name") + config_p.add_argument( + "--set", + dest="set_env", + nargs="+", + metavar="KEY=VAL", + help="Set environment variable overrides", + ) + config_p.add_argument( + "--set-header", dest="set_header", nargs="+", metavar="KEY=VAL", help="Set header overrides" + ) + config_p.set_defaults(func=cmd_mcp_config) + + # ── search ── + search_p = mcp_sub.add_parser("search", help="Search the registry") + search_p.add_argument("query", help="Search term (name, tag, description, tool name)") + search_p.add_argument("--json", dest="output_json", action="store_true", help="Output as JSON") + search_p.set_defaults(func=cmd_mcp_search) + + # ── health ── + health_p = mcp_sub.add_parser("health", help="Check server health") + health_p.add_argument("name", nargs="?", default=None, help="Server name (all if omitted)") + health_p.add_argument("--json", dest="output_json", action="store_true", help="Output as JSON") + health_p.set_defaults(func=cmd_mcp_health) + + # ── update ── + update_p = mcp_sub.add_parser( + "update", help="Update installed servers or refresh the registry index" + ) + update_p.add_argument( + "name", + nargs="?", + default=None, + help="Server name to update (omit to update all registry servers)", + ) + update_p.set_defaults(func=cmd_mcp_update) + + +# ── P0 command handlers ──────────────────────────────────────────── + + +def cmd_mcp_install(args) -> int: + """Install a server from the registry index.""" + registry = _get_registry() + _print_security_notice_if_first_use(registry._base) + if not _ensure_index_available(registry): + return 1 + + try: + entry = registry.install( + args.name, + transport=args.transport, + version=args.version, + ) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + _mark_security_notice_shown(registry._base) + + version_str = entry.get("manifest_version", "") + transport = entry.get("transport", "") + print(f"✓ Installed {args.name} v{version_str} ({transport})") + + # Prompt for credentials defined in the manifest + manifest = entry.get("manifest", {}) + _prompt_for_missing_credentials(registry, args.name, manifest) + + print("\nNext steps:") + print(f" hive mcp health {args.name} Check that the server is reachable") + print(f" hive mcp info {args.name} View server details") + return 0 + + +def cmd_mcp_add(args) -> int: + """Register a local/running MCP server.""" + registry = _get_registry() + + # Handle --from manifest.json + if args.from_manifest: + return _cmd_mcp_add_from_manifest(registry, args.from_manifest) + + if not args.name: + print( + "Error: --name is required.\n" + "Usage: hive mcp add --name my-server --transport http --url http://localhost:8080\n" + " or: hive mcp add --from manifest.json", + file=sys.stderr, + ) + return 1 + + if not args.transport: + print( + f"Error: --transport is required.\n" + f"Supported transports: stdio, http, unix, sse\n" + f"Example: hive mcp add --name {args.name} --transport http --url http://localhost:8080", + file=sys.stderr, + ) + return 1 + + try: + entry = registry.add_local( + name=args.name, + transport=args.transport, + url=args.url, + command=args.command, + args=args.args, + socket_path=args.socket_path, + description=args.description, + ) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"✓ Registered {args.name} ({entry['transport']})") + return 0 + + +def _cmd_mcp_add_from_manifest(registry, manifest_path: str) -> int: + """Register a server from a manifest.json file.""" + path = Path(manifest_path) + if not path.exists(): + print( + f"Error: manifest file not found: {manifest_path}\nCheck the path and try again.", + file=sys.stderr, + ) + return 1 + + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print( + f"Error: invalid JSON in {manifest_path}: {exc}\n" + f"Validate with: python -m json.tool {manifest_path}", + file=sys.stderr, + ) + return 1 + + name = manifest.get("name") + if not name: + print( + f"Error: manifest missing 'name' field.\nAdd a 'name' field to {manifest_path}.", + file=sys.stderr, + ) + return 1 + + try: + entry = registry.add_local(name=name, manifest=manifest) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"✓ Registered {name} from {manifest_path} ({entry['transport']})") + return 0 + + +def cmd_mcp_remove(args) -> int: + """Remove an installed server.""" + registry = _get_registry() + try: + registry.remove(args.name) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"✓ Removed {args.name}") + return 0 + + +def cmd_mcp_enable(args) -> int: + """Enable a disabled server.""" + registry = _get_registry() + try: + registry.enable(args.name) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"✓ Enabled {args.name}") + return 0 + + +def cmd_mcp_disable(args) -> int: + """Disable a server without removing it.""" + registry = _get_registry() + try: + registry.disable(args.name) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"✓ Disabled {args.name}") + return 0 + + +def cmd_mcp_list(args) -> int: + """List installed or available servers.""" + registry = _get_registry() + + if args.available: + if not _ensure_index_available(registry): + return 1 + entries = registry.list_available() + if args.output_json: + _emit_json(entries) + else: + _render_available_table(entries) + else: + entries = registry.list_installed() + if args.output_json: + safe_entries = [] + for entry in entries: + safe = dict(entry) + safe["overrides"] = _mask_overrides(safe.get("overrides", {})) + safe_entries.append(safe) + _emit_json(safe_entries) + else: + _render_installed_table(entries) + + return 0 + + +def cmd_mcp_info(args) -> int: + """Show full details for a server.""" + registry = _get_registry() + server = registry.get_server(args.name) + + if server is None: + print( + f"Error: server '{args.name}' is not installed.\n" + f"Run 'hive mcp list' to see installed servers.\n" + f"Run 'hive mcp install {args.name}' to install from registry.", + file=sys.stderr, + ) + return 1 + + # Enrich with agent usage for both JSON and human output + agents = _find_agents_using_server(registry, args.name) + if agents: + server["used_by_agents"] = agents + + if args.output_json: + safe = dict(server) + safe["overrides"] = _mask_overrides(safe.get("overrides", {})) + _emit_json(safe) + return 0 + + manifest = server.get("manifest", {}) + overrides = _mask_overrides(server.get("overrides", {})) + tools = manifest.get("tools", []) + status = manifest.get("status", "community") + hive_block = manifest.get("hive", {}) + + print(f"{server['name']}") + print("=" * 50) + + # Core info + print(f" Source: {server.get('source', '')}") + print(f" Transport: {server.get('transport', '')}") + print(f" Version: {server.get('manifest_version', 'unknown')}") + print(f" Trust tier: {status}") + print(f" Enabled: {'yes' if server.get('enabled', True) else 'no'}") + + # Description + desc = manifest.get("description", "") + if desc: + print(f" Description: {desc}") + + # Health + health = server.get("last_health_status") + if health: + health_sym = {"healthy": "✓", "unhealthy": "✗"}.get(health, "●") + print(f" Health: {health_sym} {health}") + last_check = server.get("last_health_check_at") + if last_check: + print(f" Last check: {last_check}") + last_error = server.get("last_error") + if last_error: + print(f" Last error: {last_error}") + + # Tools + if tools: + print(f"\n Tools ({len(tools)}):") + for tool in tools: + if isinstance(tool, dict): + tool_name = tool.get("name", "") + tool_desc = tool.get("description", "") + print(f" • {tool_name}: {tool_desc}" if tool_desc else f" • {tool_name}") + else: + print(f" • {tool}") + + # Overrides + env_overrides = overrides.get("env", {}) + header_overrides = overrides.get("headers", {}) + if env_overrides or header_overrides: + print("\n Overrides:") + for key in env_overrides: + print(f" env.{key} = ") + for key in header_overrides: + print(f" header.{key} = ") + + # Hive block + if hive_block: + profiles = hive_block.get("profiles", []) + if profiles: + print(f"\n Profiles: {', '.join(profiles)}") + min_ver = hive_block.get("min_version") + if min_ver: + print(f" Min Hive version: {min_ver}") + + # Agent usage + if agents: + print("\n Used by agents:") + for agent in agents: + print(f" • {agent}") + + # Timestamps + print(f"\n Installed: {server.get('installed_at', 'unknown')}") + print(f" Installed by: {server.get('installed_by', 'unknown')}") + + return 0 + + +def cmd_mcp_config(args) -> int: + """Set env or header overrides for a server.""" + registry = _get_registry() + + if not args.set_env and not args.set_header: + # Show current config + server = registry.get_server(args.name) + if server is None: + print( + f"Error: server '{args.name}' is not installed.\n" + f"Run 'hive mcp list' to see installed servers.", + file=sys.stderr, + ) + return 1 + masked = _mask_overrides(server.get("overrides", {})) + env_o = masked.get("env", {}) + header_o = masked.get("headers", {}) + if not env_o and not header_o: + print(f"No overrides set for {args.name}.") + print(f"Set one with: hive mcp config {args.name} --set KEY=VALUE") + else: + print(f"Overrides for {args.name}:") + for key in env_o: + print(f" env.{key} = ") + for key in header_o: + print(f" header.{key} = ") + return 0 + + try: + if args.set_env: + pairs = _parse_key_value_pairs(args.set_env) + for key, value in pairs.items(): + registry.set_override(args.name, key, value, override_type="env") + print(f"✓ Set {len(pairs)} env override(s) for {args.name}") + + if args.set_header: + pairs = _parse_key_value_pairs(args.set_header) + for key, value in pairs.items(): + registry.set_override(args.name, key, value, override_type="headers") + print(f"✓ Set {len(pairs)} header override(s) for {args.name}") + + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + return 0 + + +# ── P1 command handlers ──────────────────────────────────────────── + + +def cmd_mcp_search(args) -> int: + """Search the registry index.""" + registry = _get_registry() + if not _ensure_index_available(registry): + return 1 + + results = registry.search(args.query) + + if args.output_json: + _emit_json(results) + return 0 + + if not results: + print(f"No servers matching '{args.query}'.") + return 0 + + print(f"Found {len(results)} server(s) matching '{args.query}':\n") + _render_available_table(results) + return 0 + + +def cmd_mcp_health(args) -> int: + """Check server health.""" + registry = _get_registry() + + try: + results = registry.health_check(name=args.name) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + # Single server returns a flat dict, all-servers returns name->dict + if args.name: + results = {args.name: results} + + if args.output_json: + _emit_json(results) + return 0 + + for name, result in results.items(): + status = result.get("status", "unknown") + tools = result.get("tools", 0) + error = result.get("error") + sym = {"healthy": "✓", "unhealthy": "✗"}.get(status, "●") + + print(f" {sym} {name}: {status}", end="") + if status == "healthy" and tools: + print(f" ({tools} tools)") + elif error: + print(f"\n Error: {error}") + else: + print() + + return 0 + + +def cmd_mcp_update(args) -> int: + """Update a single server, or refresh the index and update all registry servers.""" + registry = _get_registry() + + if args.name: + return _cmd_mcp_update_server(args.name, registry) + + # Step 1: refresh the registry index + try: + count = registry.update_index() + except Exception as exc: + print( + f"Error: failed to update registry index: {exc}\n" + f"Check your network connection and try again.", + file=sys.stderr, + ) + return 1 + + print(f"✓ Registry index updated ({count} servers available)") + + # Step 2: update all installed registry servers (skip local/pinned) + installed = registry.list_installed() + registry_servers = [ + s for s in installed if s.get("source") == "registry" and not s.get("pinned") + ] + + if not registry_servers: + return 0 + + print(f"\nUpdating {len(registry_servers)} installed server(s)...") + errors = 0 + for server in registry_servers: + name = server["name"] + rc = _cmd_mcp_update_server(name, registry) + if rc != 0: + errors += 1 + + return 1 if errors else 0 + + +def _cmd_mcp_update_server(name: str, registry=None) -> int: + """Bridge: reinstall a server from the latest index. + + This is a temporary bridge until #6355 adds proper version diffing, + tool-signature change detection, and --dry-run support. + """ + if registry is None: + registry = _get_registry() + + server = registry.get_server(name) + if server is None: + print( + f"Error: server '{name}' is not installed.\n" + f"Run 'hive mcp install {name}' to install it.", + file=sys.stderr, + ) + return 1 + + if server.get("source") != "registry": + print( + f"Error: '{name}' is a local server and cannot be updated from the registry.\n" + f"Use 'hive mcp remove {name}' and 'hive mcp add' to re-register it.", + file=sys.stderr, + ) + return 1 + + if server.get("pinned"): + print( + f"Error: '{name}' is pinned to v{server.get('manifest_version', '?')}.\n" + f"To update a pinned server, remove and reinstall:\n" + f" hive mcp remove {name} && hive mcp install {name}", + file=sys.stderr, + ) + return 1 + + # Refresh index, then reinstall + if not _ensure_index_available(registry): + return 1 + + old_version = server.get("manifest_version", "unknown") + transport = server.get("transport") + overrides = server.get("overrides", {}) + was_enabled = server.get("enabled", True) + + # Save the full entry before removing so we can restore on failure + saved_entry = dict(server) + saved_entry.pop("name", None) + + try: + registry.remove(name) + entry = registry.install(name, transport=transport) + except ValueError as exc: + # Restore the original entry so update doesn't become an uninstall + data = registry._read_installed() + data["servers"][name] = saved_entry + registry._write_installed(data) + print( + f"Error: {exc}\nServer '{name}' has been restored to its previous state.", + file=sys.stderr, + ) + return 1 + + new_version = entry.get("manifest_version", "unknown") + + # Restore prior state from the previous installation + for key, value in overrides.get("env", {}).items(): + registry.set_override(name, key, value, override_type="env") + for key, value in overrides.get("headers", {}).items(): + registry.set_override(name, key, value, override_type="headers") + if not was_enabled: + registry.disable(name) + + if old_version == new_version: + print(f"✓ {name} is already at v{new_version}") + else: + print(f"✓ Updated {name}: v{old_version} → v{new_version}") + + return 0 diff --git a/core/framework/runner/runner.py b/core/framework/runner/runner.py index 398fcfba42..901fd3605d 100644 --- a/core/framework/runner/runner.py +++ b/core/framework/runner/runner.py @@ -1121,6 +1121,9 @@ def __init__( if mcp_config_path.exists(): self._load_mcp_servers_from_config(mcp_config_path) + # Auto-discover registry-selected MCP servers from mcp_registry.json + self._load_registry_mcp_servers(agent_path) + @staticmethod def _import_agent_module(agent_path: Path): """Import an agent package from its directory path. @@ -1424,6 +1427,45 @@ def _load_mcp_servers_from_config(self, config_path: Path) -> None: """Load and register MCP servers from a configuration file.""" self._tool_registry.load_mcp_config(config_path) + def _load_registry_mcp_servers(self, agent_path: Path) -> None: + """Load and register MCP servers selected via ``mcp_registry.json``.""" + from framework.runner.mcp_registry import MCPRegistry + + try: + registry = MCPRegistry() + registry.initialize() + server_configs = registry.load_agent_selection(agent_path) + except Exception as exc: + logger.warning( + "Failed to load MCP registry servers for '%s': %s", + agent_path.name, + exc, + ) + return + + if not server_configs: + return + + results = self._tool_registry.load_registry_servers(server_configs) + loaded = [result for result in results if result["status"] == "loaded"] + skipped = [result for result in results if result["status"] != "loaded"] + + logger.info( + "Loaded %d/%d MCP registry server(s) for agent '%s'", + len(loaded), + len(results), + agent_path.name, + ) + if skipped: + logger.info( + "Skipped MCP registry servers for agent '%s': %s", + agent_path.name, + [ + {"server": result["server"], "reason": result["skipped_reason"]} + for result in skipped + ], + ) + def set_approval_callback(self, callback: Callable) -> None: """ Set a callback for human-in-the-loop approval during execution. @@ -1479,20 +1521,26 @@ def _setup(self, event_bus=None) -> None: # Get OAuth token from Claude Code subscription api_key = get_claude_code_token() if not api_key: - print("Warning: Claude Code subscription configured but no token found.") - print("Run 'claude' to authenticate, then try again.") + logger.warning( + "Claude Code subscription configured but no token found. " + "Run 'claude' to authenticate, then try again." + ) elif use_codex: # Get OAuth token from Codex subscription api_key = get_codex_token() if not api_key: - print("Warning: Codex subscription configured but no token found.") - print("Run 'codex' to authenticate, then try again.") + logger.warning( + "Codex subscription configured but no token found. " + "Run 'codex' to authenticate, then try again." + ) elif use_kimi_code: # Get API key from Kimi Code CLI config (~/.kimi/config.toml) api_key = get_kimi_code_token() if not api_key: - print("Warning: Kimi Code subscription configured but no key found.") - print("Run 'kimi /login' to authenticate, then try again.") + logger.warning( + "Kimi Code subscription configured but no key found. " + "Run 'kimi /login' to authenticate, then try again." + ) elif use_antigravity: pass # AntigravityProvider handles credentials internally @@ -1577,8 +1625,12 @@ def _setup(self, event_bus=None) -> None: if api_key_env: os.environ[api_key_env] = api_key elif api_key_env: - print(f"Warning: {api_key_env} not set. LLM calls will fail.") - print(f"Set it with: export {api_key_env}=your-api-key") + logger.warning( + "%s not set. LLM calls will fail. " + "Set it with: export %s=your-api-key", + api_key_env, + api_key_env, + ) # Fail fast if the agent needs an LLM but none was configured if self._llm is None: diff --git a/core/framework/runner/tool_registry.py b/core/framework/runner/tool_registry.py index e97b3d1d44..ecaca5a1cc 100644 --- a/core/framework/runner/tool_registry.py +++ b/core/framework/runner/tool_registry.py @@ -16,6 +16,8 @@ logger = logging.getLogger(__name__) +_INPUT_LOG_MAX_LEN = 500 + # Per-execution context overrides. Each asyncio task (and thus each # concurrent graph execution) gets its own copy, so there are no races # when multiple ExecutionStreams run in parallel. @@ -278,6 +280,17 @@ async def _await_and_wrap(): r = await result return _wrap_result(tool_use.id, r) except Exception as exc: + inputs_str = json.dumps(tool_use.input, default=str) + if len(inputs_str) > _INPUT_LOG_MAX_LEN: + inputs_str = inputs_str[:_INPUT_LOG_MAX_LEN] + "...(truncated)" + logger.error( + "Async tool '%s' failed (tool_use_id=%s): %s\nInputs: %s", + tool_use.name, + tool_use.id, + exc, + inputs_str, + exc_info=True, + ) return ToolResult( tool_use_id=tool_use.id, content=json.dumps({"error": str(exc)}), @@ -288,6 +301,17 @@ async def _await_and_wrap(): return _wrap_result(tool_use.id, result) except Exception as e: + inputs_str = json.dumps(tool_use.input, default=str) + if len(inputs_str) > _INPUT_LOG_MAX_LEN: + inputs_str = inputs_str[:_INPUT_LOG_MAX_LEN] + "...(truncated)" + logger.error( + "Tool '%s' execution failed for tool_use_id=%s: %s\nInputs: %s", + tool_use.name, + tool_use.id, + e, + inputs_str, + exc_info=True, + ) return ToolResult( tool_use_id=tool_use.id, content=json.dumps({"error": str(e)}), @@ -462,30 +486,81 @@ def load_mcp_config(self, config_path: Path) -> None: # Treat top-level keys as server names server_list = [{"name": name, **cfg} for name, cfg in config.items()] - for server_config in server_list: - server_config = self._resolve_mcp_server_config(server_config, base_dir) - for _attempt in range(2): - try: - self.register_mcp_server(server_config) - break - except Exception as e: - name = server_config.get("name", "unknown") - if _attempt == 0: - logger.warning( - "MCP server '%s' failed to register, retrying in 2s: %s", - name, - e, - ) - import time - - time.sleep(2) - else: - logger.warning("MCP server '%s' failed after retry: %s", name, e) + resolved_server_list = [ + self._resolve_mcp_server_config(server_config, base_dir) + for server_config in server_list + ] + self.load_registry_servers(resolved_server_list, log_summary=False) # Snapshot credential files and ADEN_API_KEY so we can detect mid-session changes self._mcp_cred_snapshot = self._snapshot_credentials() self._mcp_aden_key_snapshot = os.environ.get("ADEN_API_KEY") + def _register_mcp_server_with_retry( + self, + server_config: dict[str, Any], + ) -> tuple[bool, int, str | None]: + """Register a single MCP server with one retry for transient failures.""" + name = server_config.get("name", "unknown") + last_error: str | None = None + + for attempt in range(2): + try: + count = self.register_mcp_server(server_config) + if count > 0: + return True, count, None + last_error = "registered 0 tools" + except Exception as exc: + last_error = str(exc) + + if attempt == 0: + logger.warning( + "MCP server '%s' failed to register, retrying in 2s: %s", + name, + last_error, + ) + import time + + time.sleep(2) + else: + logger.warning("MCP server '%s' failed after retry: %s", name, last_error) + + return False, 0, last_error + + def load_registry_servers( + self, + server_list: list[dict[str, Any]], + *, + log_summary: bool = True, + ) -> list[dict[str, Any]]: + """Register resolved registry-selected MCP servers with retry and status tracking.""" + results: list[dict[str, Any]] = [] + + for server_config in server_list: + name = server_config.get("name", "unknown") + success, tools_loaded, error = self._register_mcp_server_with_retry(server_config) + result = { + "server": name, + "status": "loaded" if success else "skipped", + "tools_loaded": tools_loaded, + "skipped_reason": None if success else (error or "unknown error"), + } + results.append(result) + + if log_summary: + logger.info( + "MCP registry server resolution", + extra={ + "event": "mcp_registry_server_resolution", + "server": result["server"], + "status": result["status"], + "tools_loaded": result["tools_loaded"], + "skipped_reason": result["skipped_reason"], + }, + ) + + return results + def register_mcp_server( self, server_config: dict[str, Any], @@ -524,6 +599,7 @@ def register_mcp_server( cwd=server_config.get("cwd"), url=server_config.get("url"), headers=server_config.get("headers", {}), + socket_path=server_config.get("socket_path"), description=server_config.get("description", ""), ) @@ -588,7 +664,16 @@ def executor(inputs: dict) -> Any: return result[0] return result except Exception as e: - logger.error(f"MCP tool '{tool_name}' execution failed: {e}") + inputs_str = json.dumps(inputs, default=str) + if len(inputs_str) > _INPUT_LOG_MAX_LEN: + inputs_str = inputs_str[:_INPUT_LOG_MAX_LEN] + "...(truncated)" + logger.error( + "MCP tool '%s' execution failed: %s\nInputs: %s", + tool_name, + e, + inputs_str, + exc_info=True, + ) return {"error": str(e)} return executor diff --git a/core/framework/runtime/event_bus.py b/core/framework/runtime/event_bus.py index d3c7ef013c..90aa186aad 100644 --- a/core/framework/runtime/event_bus.py +++ b/core/framework/runtime/event_bus.py @@ -535,8 +535,8 @@ async def run_handler(handler: EventHandler) -> None: async with self._semaphore: try: await handler(event) - except Exception as e: - logger.error(f"Handler error for {event.type}: {e}") + except Exception: + logger.exception(f"Handler error for {event.type}") # Run all handlers concurrently await asyncio.gather(*[run_handler(h) for h in handlers], return_exceptions=True) diff --git a/core/framework/server/queen_orchestrator.py b/core/framework/server/queen_orchestrator.py index bba5ec2283..4518fd8e9b 100644 --- a/core/framework/server/queen_orchestrator.py +++ b/core/framework/server/queen_orchestrator.py @@ -62,6 +62,7 @@ async def create_queen( from framework.agents.queen.nodes.thinking_hook import select_expert_persona from framework.graph.event_loop_node import HookContext, HookResult from framework.graph.executor import GraphExecutor + from framework.runner.mcp_registry import MCPRegistry from framework.runner.tool_registry import ToolRegistry from framework.runtime.core import Runtime from framework.runtime.event_bus import AgentEvent, EventType @@ -86,6 +87,16 @@ async def create_queen( except Exception: logger.warning("Queen: MCP config failed to load", exc_info=True) + try: + registry = MCPRegistry() + registry.initialize() + registry_configs = registry.load_agent_selection(queen_pkg_dir) + if registry_configs: + results = queen_registry.load_registry_servers(registry_configs) + logger.info("Queen: loaded MCP registry servers: %s", results) + except Exception: + logger.warning("Queen: MCP registry config failed to load", exc_info=True) + # ---- Phase state -------------------------------------------------- initial_phase = "staging" if worker_identity else "planning" phase_state = QueenPhaseState(phase=initial_phase, event_bus=session.event_bus) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 02dfdf6baf..1d60cf5252 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route } from "react-router-dom"; import Home from "./pages/home"; import MyAgents from "./pages/my-agents"; import Workspace from "./pages/workspace"; +import NotFound from "./pages/not-found"; function App() { return ( @@ -9,6 +10,7 @@ function App() { } /> } /> } /> + } /> ); } diff --git a/core/frontend/src/pages/not-found.tsx b/core/frontend/src/pages/not-found.tsx new file mode 100644 index 0000000000..533c07724d --- /dev/null +++ b/core/frontend/src/pages/not-found.tsx @@ -0,0 +1,19 @@ +import { Link } from "react-router-dom"; + +export default function NotFound() { + return ( +
+

404

+

Page not found

+

+ The page you’re looking for doesn’t exist. +

+ + Back to Home + +
+ ); +} diff --git a/core/pyproject.toml b/core/pyproject.toml index 8a8ec313b9..8fff708de6 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "pydantic>=2.0", "anthropic>=0.40.0", "httpx>=0.27.0", - "litellm>=1.81.0", + "litellm==1.81.7", # pinned: supply chain attack in >=1.82.7 (adenhq/hive#6783) "mcp>=1.0.0", "fastmcp>=2.0.0", "croniter>=1.4.0", diff --git a/core/tests/test_agent_runner_mcp_registry.py b/core/tests/test_agent_runner_mcp_registry.py new file mode 100644 index 0000000000..8e6cb4b39b --- /dev/null +++ b/core/tests/test_agent_runner_mcp_registry.py @@ -0,0 +1,243 @@ +"""Tests for AgentRunner MCP registry integration.""" + +import json +from pathlib import Path + +from framework.graph.edge import GraphSpec +from framework.graph.goal import Goal +from framework.graph.node import NodeSpec +from framework.runner.mcp_registry import MCPRegistry +from framework.runner.runner import AgentRunner + + +def _make_graph() -> GraphSpec: + return GraphSpec( + id="test-graph", + goal_id="goal-1", + entry_node="start", + terminal_nodes=["start"], + nodes=[NodeSpec(id="start", name="Start", description="Start node")], + edges=[], + ) + + +class _FakeRegistry: + def __init__(self, returned_configs): + self._returned_configs = returned_configs + self.initialize_calls = 0 + self.loaded_paths: list[Path] = [] + + def initialize(self) -> None: + self.initialize_calls += 1 + + def load_agent_selection(self, agent_path: Path): + self.loaded_paths.append(agent_path) + return list(self._returned_configs) + + +def test_agent_runner_loads_registry_selected_servers(tmp_path, monkeypatch): + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "mcp_registry.json").write_text('{"include": ["jira"]}', encoding="utf-8") + + fake_registry = _FakeRegistry( + [ + { + "name": "jira", + "transport": "http", + "url": "http://localhost:4010", + "headers": {}, + "description": "Jira", + } + ] + ) + registered: list[dict] = [] + + monkeypatch.setattr("framework.runner.mcp_registry.MCPRegistry", lambda: fake_registry) + monkeypatch.setattr( + "framework.runner.runner.run_preload_validation", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr(AgentRunner, "_resolve_default_model", staticmethod(lambda: "test-model")) + monkeypatch.setattr( + "framework.runner.tool_registry.ToolRegistry.register_mcp_server", + lambda self, server_config, use_connection_manager=True: ( + registered.append(server_config) or 1 + ), + ) + + AgentRunner( + agent_path=agent_dir, + graph=_make_graph(), + goal=Goal(id="goal-1", name="Goal", description="desc"), + storage_path=tmp_path / "storage", + interactive=False, + skip_credential_validation=True, + ) + + assert fake_registry.initialize_calls == 1 + assert fake_registry.loaded_paths == [agent_dir] + assert [config["name"] for config in registered] == ["jira"] + + +def test_agent_runner_skips_registry_when_no_servers_selected(tmp_path, monkeypatch): + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + + fake_registry = _FakeRegistry([]) + registered: list[dict] = [] + + monkeypatch.setattr("framework.runner.mcp_registry.MCPRegistry", lambda: fake_registry) + monkeypatch.setattr( + "framework.runner.runner.run_preload_validation", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr(AgentRunner, "_resolve_default_model", staticmethod(lambda: "test-model")) + monkeypatch.setattr( + "framework.runner.tool_registry.ToolRegistry.register_mcp_server", + lambda self, server_config, use_connection_manager=True: ( + registered.append(server_config) or 1 + ), + ) + + AgentRunner( + agent_path=agent_dir, + graph=_make_graph(), + goal=Goal(id="goal-1", name="Goal", description="desc"), + storage_path=tmp_path / "storage", + interactive=False, + skip_credential_validation=True, + ) + + assert fake_registry.initialize_calls == 1 + assert fake_registry.loaded_paths == [agent_dir] + assert registered == [] + + +def test_agent_runner_logs_actual_registry_load_results(tmp_path, monkeypatch): + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "mcp_registry.json").write_text('{"include": ["jira", "slack"]}', encoding="utf-8") + + fake_registry = _FakeRegistry( + [ + {"name": "jira", "transport": "http", "url": "http://localhost:4010"}, + {"name": "slack", "transport": "http", "url": "http://localhost:4020"}, + ] + ) + log_messages: list[str] = [] + + monkeypatch.setattr("framework.runner.mcp_registry.MCPRegistry", lambda: fake_registry) + monkeypatch.setattr( + "framework.runner.runner.run_preload_validation", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr(AgentRunner, "_resolve_default_model", staticmethod(lambda: "test-model")) + monkeypatch.setattr( + "framework.runner.tool_registry.ToolRegistry.load_registry_servers", + lambda self, server_configs: [ + {"server": "jira", "status": "loaded", "tools_loaded": 2, "skipped_reason": None}, + { + "server": "slack", + "status": "skipped", + "tools_loaded": 0, + "skipped_reason": "registered 0 tools", + }, + ], + ) + monkeypatch.setattr( + "framework.runner.runner.logger.info", + lambda message, *args: log_messages.append(message % args if args else str(message)), + ) + + AgentRunner( + agent_path=agent_dir, + graph=_make_graph(), + goal=Goal(id="goal-1", name="Goal", description="desc"), + storage_path=tmp_path / "storage", + interactive=False, + skip_credential_validation=True, + ) + + assert any("Loaded 1/2 MCP registry server(s)" in message for message in log_messages) + assert any("Skipped MCP registry servers" in message for message in log_messages) + + +def test_agent_runner_survives_malformed_registry_json(tmp_path, monkeypatch): + """Agent startup must not crash when mcp_registry.json has invalid JSON.""" + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "mcp_registry.json").write_text("{bad json", encoding="utf-8") + + warnings: list[str] = [] + monkeypatch.setattr( + "framework.runner.runner.run_preload_validation", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr(AgentRunner, "_resolve_default_model", staticmethod(lambda: "test-model")) + monkeypatch.setattr( + "framework.runner.runner.logger.warning", + lambda message, *args: warnings.append(message % args if args else str(message)), + ) + + AgentRunner( + agent_path=agent_dir, + graph=_make_graph(), + goal=Goal(id="goal-1", name="Goal", description="desc"), + storage_path=tmp_path / "storage", + interactive=False, + skip_credential_validation=True, + ) + + assert any("Failed to load MCP registry servers" in w for w in warnings) + + +def test_integration_real_registry_to_agent_runner(tmp_path, monkeypatch): + """Integration: real MCPRegistry on disk → mcp_registry.json → AgentRunner.""" + # Set up a real registry with a local server + registry_base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=registry_base) + registry.initialize() + registry.add_local(name="jira", transport="http", url="http://localhost:4010") + + # Write mcp_registry.json in the agent dir + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "mcp_registry.json").write_text( + json.dumps({"include": ["jira"]}), encoding="utf-8" + ) + + # Patch MCPRegistry to use our tmp_path base, but keep real logic + original_init = MCPRegistry.__init__ + + def patched_init(self, base_path=None): + original_init(self, base_path=registry_base) + + monkeypatch.setattr(MCPRegistry, "__init__", patched_init) + monkeypatch.setattr( + "framework.runner.runner.run_preload_validation", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr(AgentRunner, "_resolve_default_model", staticmethod(lambda: "test-model")) + + registered: list[dict] = [] + monkeypatch.setattr( + "framework.runner.tool_registry.ToolRegistry.register_mcp_server", + lambda self, server_config, use_connection_manager=True: ( + registered.append(server_config) or 1 + ), + ) + + AgentRunner( + agent_path=agent_dir, + graph=_make_graph(), + goal=Goal(id="goal-1", name="Goal", description="desc"), + storage_path=tmp_path / "storage", + interactive=False, + skip_credential_validation=True, + ) + + assert len(registered) == 1 + assert registered[0]["name"] == "jira" + assert registered[0]["transport"] == "http" + assert registered[0]["url"] == "http://localhost:4010" diff --git a/core/tests/test_codex_oauth.py b/core/tests/test_codex_oauth.py new file mode 100644 index 0000000000..63f6476df8 --- /dev/null +++ b/core/tests/test_codex_oauth.py @@ -0,0 +1,68 @@ +import io +import threading +import time + +import codex_oauth + + +def _redirect_url(state: str, code: str) -> str: + return f"{codex_oauth.REDIRECT_URI}?code={code}&state={state}" + + +def test_wait_for_code_accepts_valid_manual_input_after_invalid_entry(): + state = "expected-state" + stdin = io.StringIO(f"not a valid code\n{_redirect_url(state, 'manual-code')}\n") + + code = codex_oauth.wait_for_code_from_callback_or_stdin( + state, + [None], + threading.Event(), + timeout_secs=0.5, + poll_interval=0.01, + stdin=stdin, + ) + + assert code == "manual-code" + + +def test_wait_for_code_returns_callback_when_stdin_reader_fails(): + class BrokenStdin: + def readline(self) -> str: + raise OSError("stdin unavailable") + + state = "expected-state" + callback_result: list[str | None] = [None] + callback_done = threading.Event() + + def resolve_callback() -> None: + time.sleep(0.02) + callback_result[0] = "callback-code" + callback_done.set() + + threading.Thread(target=resolve_callback, daemon=True).start() + + code = codex_oauth.wait_for_code_from_callback_or_stdin( + state, + callback_result, + callback_done, + timeout_secs=0.5, + poll_interval=0.01, + stdin=BrokenStdin(), + ) + + assert code == "callback-code" + + +def test_open_browser_uses_windows_startfile(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(codex_oauth.platform, "system", lambda: "Windows") + monkeypatch.setattr(codex_oauth.os, "startfile", calls.append, raising=False) + + def fail_popen(*args, **kwargs): + raise AssertionError("Windows browser launch should not go through cmd /c start") + + monkeypatch.setattr(codex_oauth.subprocess, "Popen", fail_popen) + + assert codex_oauth.open_browser("https://example.com/path?a=1&b=2") is True + assert calls == ["https://example.com/path?a=1&b=2"] diff --git a/core/tests/test_mcp_connection_manager.py b/core/tests/test_mcp_connection_manager.py index 901d7dbc0c..c146326e18 100644 --- a/core/tests/test_mcp_connection_manager.py +++ b/core/tests/test_mcp_connection_manager.py @@ -164,9 +164,157 @@ def get(self, _path: str): assert manager.health_check("shared") is False +def test_health_check_for_stdio_returns_true_when_healthy(manager): + config = MCPServerConfig(name="shared", transport="stdio", command="echo") + manager.acquire(config) + + assert manager.health_check("shared") is True + + def test_health_check_for_stdio_returns_false_on_tools_list_error(manager): config = MCPServerConfig(name="shared", transport="stdio", command="echo") client = manager.acquire(config) client.list_tools_error = RuntimeError("broken") assert manager.health_check("shared") is False + + +def test_health_check_for_sse_uses_list_tools(manager): + config = MCPServerConfig(name="stream", transport="sse", url="http://localhost:9000/sse") + client = manager.acquire(config) + + assert manager.health_check("stream") is True + assert client.list_tools_calls >= 1 + + +def test_health_check_unknown_server_returns_false(manager): + assert manager.health_check("nonexistent") is False + + +# ── Failure-path tests ────────────────────────────────────────────── + + +class FailingConnectClient(FakeMCPClient): + """Client that raises on connect().""" + + def connect(self) -> None: + self.connect_calls += 1 + raise ConnectionError("connect failed") + + +class FailingDisconnectClient(FakeMCPClient): + """Client that raises on disconnect().""" + + def disconnect(self) -> None: + self.disconnect_calls += 1 + self._connected = False + raise RuntimeError("disconnect failed") + + +def test_acquire_cleans_up_transition_when_connect_fails(monkeypatch): + monkeypatch.setattr( + "framework.runner.mcp_connection_manager.MCPClient", + FailingConnectClient, + ) + monkeypatch.setattr(MCPConnectionManager, "_instance", None) + FailingConnectClient.instances = [] + mgr = MCPConnectionManager.get_instance() + + config = MCPServerConfig(name="broken", transport="stdio", command="echo") + + with pytest.raises(ConnectionError, match="connect failed"): + mgr.acquire(config) + + # Transition should be cleaned up, not stuck + assert "broken" not in mgr._transitions # noqa: SLF001 + assert "broken" not in mgr._pool # noqa: SLF001 + + monkeypatch.setattr(MCPConnectionManager, "_instance", None) + + +def test_release_handles_disconnect_failure(monkeypatch): + monkeypatch.setattr( + "framework.runner.mcp_connection_manager.MCPClient", + FailingDisconnectClient, + ) + monkeypatch.setattr(MCPConnectionManager, "_instance", None) + FailingDisconnectClient.instances = [] + mgr = MCPConnectionManager.get_instance() + + config = MCPServerConfig(name="flaky", transport="stdio", command="echo") + mgr.acquire(config) + + # release should not raise even if disconnect fails + mgr.release("flaky") + + # Pool should be cleaned up despite disconnect failure + assert "flaky" not in mgr._pool # noqa: SLF001 + assert "flaky" not in mgr._refcounts # noqa: SLF001 + assert "flaky" not in mgr._transitions # noqa: SLF001 + + monkeypatch.setattr(MCPConnectionManager, "_instance", None) + + +def test_reconnect_handles_old_client_disconnect_failure(monkeypatch): + call_count = 0 + + class FirstFailsThenWorks(FakeMCPClient): + """First instance fails disconnect, second works fine.""" + + def disconnect(self) -> None: + nonlocal call_count + call_count += 1 + self.disconnect_calls += 1 + self._connected = False + if call_count == 1: + raise RuntimeError("old disconnect failed") + + monkeypatch.setattr( + "framework.runner.mcp_connection_manager.MCPClient", + FirstFailsThenWorks, + ) + monkeypatch.setattr(MCPConnectionManager, "_instance", None) + FirstFailsThenWorks.instances = [] + mgr = MCPConnectionManager.get_instance() + + config = MCPServerConfig(name="flaky", transport="stdio", command="echo") + original = mgr.acquire(config) + + # reconnect should succeed even if old client disconnect fails + replacement = mgr.reconnect("flaky") + assert replacement is not original + assert "flaky" in mgr._pool # noqa: SLF001 + assert "flaky" not in mgr._transitions # noqa: SLF001 + + mgr.cleanup_all() + monkeypatch.setattr(MCPConnectionManager, "_instance", None) + + +def test_cleanup_all_handles_disconnect_failure(monkeypatch): + monkeypatch.setattr( + "framework.runner.mcp_connection_manager.MCPClient", + FailingDisconnectClient, + ) + monkeypatch.setattr(MCPConnectionManager, "_instance", None) + FailingDisconnectClient.instances = [] + mgr = MCPConnectionManager.get_instance() + + mgr.acquire(MCPServerConfig(name="a", transport="stdio", command="echo")) + mgr.acquire(MCPServerConfig(name="b", transport="stdio", command="echo")) + + # cleanup_all should not raise even if disconnects fail + mgr.cleanup_all() + + assert mgr._pool == {} # noqa: SLF001 + assert mgr._refcounts == {} # noqa: SLF001 + + monkeypatch.setattr(MCPConnectionManager, "_instance", None) + + +def test_reconnect_on_fully_released_server_raises(manager): + config = MCPServerConfig(name="gone", transport="stdio", command="echo") + manager.acquire(config) + manager.release("gone") + + with pytest.raises(KeyError, match="Unknown MCP server"): + manager.reconnect("gone") diff --git a/core/tests/test_mcp_registry.py b/core/tests/test_mcp_registry.py new file mode 100644 index 0000000000..befb01c845 --- /dev/null +++ b/core/tests/test_mcp_registry.py @@ -0,0 +1,1172 @@ +"""Tests for MCPRegistry core module.""" + +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from framework.runner.mcp_registry import MCPRegistry + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _write_mock_index(cache_dir: Path, servers: dict) -> None: + cache_dir.mkdir(parents=True, exist_ok=True) + (cache_dir / "registry_index.json").write_text( + json.dumps({"servers": servers}), encoding="utf-8" + ) + + +def _setup_registry_with_servers(tmp_path: Path) -> MCPRegistry: + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + + registry.add_local(name="jira", transport="stdio", command="uvx", args=["jira-mcp"]) + registry.add_local(name="slack", transport="http", url="http://localhost:4020") + registry.add_local(name="github", transport="http", url="http://localhost:4030") + + data = registry._read_installed() + data["servers"]["jira"]["manifest"]["tags"] = ["productivity", "pm"] + data["servers"]["slack"]["manifest"]["tags"] = ["productivity", "messaging"] + data["servers"]["github"]["manifest"]["tags"] = ["dev"] + data["servers"]["jira"]["manifest"]["hive"] = {"profiles": ["productivity"]} + data["servers"]["slack"]["manifest"]["hive"] = {"profiles": ["productivity"]} + data["servers"]["github"]["manifest"]["hive"] = {"profiles": ["developer"]} + registry._write_installed(data) + return registry + + +# ── Initialization ────────────────────────────────────────────────── + + +def test_init_creates_directory_structure(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + assert (tmp_path / "mcp_registry" / "config.json").exists() + assert (tmp_path / "mcp_registry" / "installed.json").exists() + assert (tmp_path / "mcp_registry" / "cache").is_dir() + + +def test_init_default_config(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + config = json.loads((tmp_path / "mcp_registry" / "config.json").read_text()) + assert "index_url" in config + assert "refresh_interval_hours" in config + + +def test_init_preserves_existing(tmp_path: Path): + base = tmp_path / "mcp_registry" + base.mkdir(parents=True) + (base / "installed.json").write_text(json.dumps({"servers": {"jira": {"enabled": True}}})) + registry = MCPRegistry(base_path=base) + registry.initialize() + assert "jira" in json.loads((base / "installed.json").read_text())["servers"] + + +# ── add_local ─────────────────────────────────────────────────────── + + +def test_add_local_http(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="my-db", transport="http", url="http://localhost:9090") + entry = registry._read_installed()["servers"]["my-db"] + assert entry["source"] == "local" + assert entry["transport"] == "http" + assert entry["enabled"] is True + + +def test_add_local_duplicate_raises(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="my-db", transport="http", url="http://localhost:9090") + with pytest.raises(ValueError, match="already exists"): + registry.add_local(name="my-db", transport="http", url="http://localhost:9090") + + +def test_add_local_stdio(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local( + name="tools", + transport="stdio", + command="uvx", + args=["my-mcp"], + cwd="/opt/tools", + description="My tools", + ) + entry = registry._read_installed()["servers"]["tools"] + assert entry["manifest"]["stdio"]["command"] == "uvx" + assert entry["manifest"]["stdio"]["cwd"] == "/opt/tools" + assert entry["manifest"]["description"] == "My tools" + + +def test_add_local_unix(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="db", transport="unix", socket_path="/tmp/mcp.sock") + entry = registry._read_installed()["servers"]["db"] + assert entry["transport"] == "unix" + assert entry["manifest"]["unix"]["socket_path"] == "/tmp/mcp.sock" + + +def test_add_local_sse(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="stream", transport="sse", url="http://localhost:8080/sse") + entry = registry._read_installed()["servers"]["stream"] + assert entry["transport"] == "sse" + + +def test_add_local_stdio_requires_command(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="command is required"): + registry.add_local(name="x", transport="stdio") + + +def test_add_local_http_requires_url(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="url is required"): + registry.add_local(name="x", transport="http") + + +def test_add_local_unix_requires_socket_path(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="socket_path is required"): + registry.add_local(name="x", transport="unix") + + +def test_add_local_unsupported_transport(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="Unsupported transport"): + registry.add_local(name="x", transport="grpc") + + +# ── install ───────────────────────────────────────────────────────── + + +def test_install_from_index(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": { + "name": "jira", + "version": "1.2.0", + "transport": {"supported": ["stdio"], "default": "stdio"}, + "stdio": {"command": "uvx", "args": ["jira-mcp-server"]}, + } + }, + ) + registry.install("jira") + entry = registry._read_installed()["servers"]["jira"] + assert entry["source"] == "registry" + assert entry["transport"] == "stdio" + assert entry["manifest"]["name"] == "jira" + assert entry["manifest"]["version"] == "1.2.0" + assert entry["manifest"]["stdio"]["command"] == "uvx" + + +def test_install_not_found_raises(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index(base / "cache", {}) + with pytest.raises(ValueError, match="not found"): + registry.install("nonexistent") + + +def test_install_duplicate_raises(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": { + "name": "jira", + "version": "1.0.0", + "transport": {"default": "stdio"}, + "stdio": {"command": "uvx"}, + } + }, + ) + registry.install("jira") + with pytest.raises(ValueError, match="already exists"): + registry.install("jira") + + +def test_install_version_pin(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + {"slack": {"name": "slack", "version": "2.0.0", "transport": {"default": "http"}}}, + ) + registry.install("slack", version="2.0.0") + entry = registry._read_installed()["servers"]["slack"] + assert entry["pinned"] is True + + +def test_install_version_mismatch_raises(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + {"jira": {"name": "jira", "version": "1.2.0", "transport": {"default": "stdio"}}}, + ) + with pytest.raises(ValueError, match="Version mismatch"): + registry.install("jira", version="2.0.0") + + +# ── remove / enable / disable ────────────────────────────────────── + + +def test_remove(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="db", transport="http", url="http://localhost:9090") + registry.remove("db") + assert "db" not in registry._read_installed()["servers"] + + +def test_remove_nonexistent_raises(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="not installed"): + registry.remove("ghost") + + +def test_enable_disable(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="db", transport="http", url="http://localhost:9090") + registry.disable("db") + assert registry._read_installed()["servers"]["db"]["enabled"] is False + registry.enable("db") + assert registry._read_installed()["servers"]["db"]["enabled"] is True + + +# ── list / get / search ───────────────────────────────────────────── + + +def test_list_installed(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="a", transport="http", url="http://localhost:1") + registry.add_local(name="b", transport="http", url="http://localhost:2") + assert len(registry.list_installed()) == 2 + + +def test_get_server(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="jira", transport="http", url="http://localhost:4010") + assert registry.get_server("jira") is not None + assert registry.get_server("nonexistent") is None + + +def test_search_by_name(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": {"name": "jira", "description": "Issues", "tags": []}, + "slack": {"name": "slack", "description": "Chat", "tags": []}, + }, + ) + assert len(registry.search("jira")) == 1 + + +def test_search_by_tag(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": {"name": "jira", "tags": ["pm"]}, + "linear": {"name": "linear", "tags": ["pm"]}, + "slack": {"name": "slack", "tags": ["chat"]}, + }, + ) + assert len(registry.search("pm")) == 2 + + +def test_search_by_description(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": {"name": "jira", "description": "Manage issues", "tags": []}, + "slack": {"name": "slack", "description": "Send messages", "tags": []}, + }, + ) + assert len(registry.search("issues")) == 1 + + +def test_search_by_tool_name(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": {"name": "jira", "tags": [], "tools": [{"name": "create_issue"}]}, + "slack": {"name": "slack", "tags": [], "tools": [{"name": "send_message"}]}, + }, + ) + assert len(registry.search("create_issue")) == 1 + + +def test_list_available(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index(base / "cache", {"a": {"name": "a"}, "b": {"name": "b"}}) + assert len(registry.list_available()) == 2 + + +# ── set_override ──────────────────────────────────────────────────── + + +def test_set_override_env(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="jira", transport="http", url="http://localhost:4010") + registry.set_override("jira", "TOKEN", "secret") + assert registry._read_installed()["servers"]["jira"]["overrides"]["env"]["TOKEN"] == "secret" + + +def test_set_override_headers(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="jira", transport="http", url="http://localhost:4010") + registry.set_override("jira", "Authorization", "Bearer x", override_type="headers") + entry = registry._read_installed()["servers"]["jira"] + assert entry["overrides"]["headers"]["Authorization"] == "Bearer x" + + +def test_set_override_invalid_type(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="jira", transport="http", url="http://localhost:4010") + with pytest.raises(ValueError, match="Invalid override type"): + registry.set_override("jira", "k", "v", override_type="cookies") + + +# ── update_index ──────────────────────────────────────────────────── + + +def test_update_index_writes_cache(tmp_path: Path, monkeypatch): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + + class MockResponse: + status_code = 200 + + def json(self): + return {"servers": {"jira": {"name": "jira"}}} + + def raise_for_status(self): + pass + + monkeypatch.setattr("framework.runner.mcp_registry.httpx.get", lambda *a, **kw: MockResponse()) + registry.update_index() + cached = json.loads((base / "cache" / "registry_index.json").read_text()) + assert "jira" in cached["servers"] + assert (base / "cache" / "last_fetched").exists() + assert not (base / "cache" / "last_fetched.json").exists() + + +def test_update_index_network_error(tmp_path: Path, monkeypatch): + import httpx as _httpx + + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + monkeypatch.setattr( + "framework.runner.mcp_registry.httpx.get", + lambda *a, **kw: (_ for _ in ()).throw(_httpx.ConnectError("fail")), + ) + with pytest.raises(_httpx.ConnectError): + registry.update_index() + + +# ── is_index_stale ────────────────────────────────────────────────── + + +def test_is_index_stale_no_cache(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + assert registry.is_index_stale() is True + + +def test_is_index_stale_fresh(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + MCPRegistry._write_json( + base / "cache" / "last_fetched", + {"timestamp": datetime.now(UTC).isoformat()}, + ) + assert registry.is_index_stale() is False + + +def test_is_index_stale_expired(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + old = datetime.now(UTC) - timedelta(hours=48) + MCPRegistry._write_json( + base / "cache" / "last_fetched", + {"timestamp": old.isoformat()}, + ) + assert registry.is_index_stale() is True + + +def test_is_index_stale_supports_legacy_filename(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + MCPRegistry._write_json( + base / "cache" / "last_fetched.json", + {"timestamp": datetime.now(UTC).isoformat()}, + ) + assert registry.is_index_stale() is False + + +def test_is_index_stale_missing_timestamp_returns_true(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + MCPRegistry._write_json(base / "cache" / "last_fetched", {"bad": "data"}) + assert registry.is_index_stale() is True + + +# ── resolve_for_agent ─────────────────────────────────────────────── + + +def test_resolve_include(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + configs = registry.resolve_for_agent(include=["jira", "slack"]) + assert [c.name for c in configs] == ["jira", "slack"] + + +def test_resolve_exclude(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + configs = registry.resolve_for_agent(include=["jira", "slack", "github"], exclude=["github"]) + assert "github" not in [c.name for c in configs] + + +def test_resolve_tags(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + configs = registry.resolve_for_agent(tags=["productivity"]) + names = [c.name for c in configs] + assert "jira" in names and "slack" in names and "github" not in names + + +def test_resolve_profile(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + configs = registry.resolve_for_agent(profile="productivity") + names = [c.name for c in configs] + assert "jira" in names and "slack" in names + + +def test_resolve_profile_all(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + assert len(registry.resolve_for_agent(profile="all")) == 3 + + +def test_resolve_max_tools(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + data = registry._read_installed() + data["servers"]["jira"]["manifest"]["tools"] = [{"name": "a"}, {"name": "b"}] + data["servers"]["slack"]["manifest"]["tools"] = [{"name": "c"}, {"name": "d"}] + data["servers"]["github"]["manifest"]["tools"] = [{"name": "e"}] + registry._write_installed(data) + configs = registry.resolve_for_agent(profile="all", max_tools=3) + total = sum( + len(registry._read_installed()["servers"][c.name]["manifest"].get("tools", [])) + for c in configs + ) + assert total <= 3 and len(configs) >= 1 + + +def test_resolve_skips_disabled(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + registry.disable("jira") + configs = registry.resolve_for_agent(include=["jira", "slack"]) + assert "jira" not in [c.name for c in configs] + + +def test_resolve_missing_server_warns(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + assert len(registry.resolve_for_agent(include=["jira", "ghost"])) == 1 + + +def test_resolve_versions_match(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": { + "name": "jira", + "version": "1.2.0", + "transport": {"default": "stdio"}, + "stdio": {"command": "uvx"}, + } + }, + ) + registry.install("jira") + assert len(registry.resolve_for_agent(include=["jira"], versions={"jira": "1.2.0"})) == 1 + assert len(registry.resolve_for_agent(include=["jira"], versions={"jira": "9.9.9"})) == 0 + + +# ── _manifest_to_server_config ────────────────────────────────────── + + +def test_config_merges_env(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="t", transport="stdio", command="uvx", env={"A": "1"}) + registry.set_override("t", "B", "2") + configs = registry.resolve_for_agent(include=["t"]) + assert configs[0].env["A"] == "1" and configs[0].env["B"] == "2" + + +def test_config_overrides_win(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="t", transport="stdio", command="uvx", env={"K": "old"}) + registry.set_override("t", "K", "new") + assert registry.resolve_for_agent(include=["t"])[0].env["K"] == "new" + + +def test_config_includes_cwd(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="t", transport="stdio", command="uvx", cwd="/opt") + assert registry.resolve_for_agent(include=["t"])[0].cwd == "/opt" + + +def test_config_includes_description(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="t", transport="stdio", command="uvx", description="desc") + assert registry.resolve_for_agent(include=["t"])[0].description == "desc" + + +def test_config_unix_transport(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="u", transport="unix", socket_path="/tmp/s.sock") + c = registry.resolve_for_agent(include=["u"])[0] + assert c.transport == "unix" and c.socket_path == "/tmp/s.sock" + + +def test_config_unsupported_transport_returns_none(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + data = registry._read_installed() + data["servers"]["x"] = { + "source": "local", + "transport": "grpc", + "enabled": True, + "manifest": {"name": "x", "transport": {"default": "grpc"}}, + "overrides": {"env": {}, "headers": {}}, + } + registry._write_installed(data) + assert len(registry.resolve_for_agent(include=["x"])) == 0 + + +# ── _server_config_to_dict ────────────────────────────────────────── + + +def test_server_config_to_dict(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="t", transport="stdio", command="uvx", cwd="/opt", description="d") + config = registry.resolve_for_agent(include=["t"])[0] + d = MCPRegistry._server_config_to_dict(config) + assert d["name"] == "t" and d["transport"] == "stdio" + assert d["cwd"] == "/opt" and d["description"] == "d" + + +# ── load_agent_selection ──────────────────────────────────────────── + + +def test_load_agent_selection(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "mcp_registry.json").write_text(json.dumps({"include": ["jira", "slack"]})) + dicts = registry.load_agent_selection(agent_dir) + assert len(dicts) == 2 and all("transport" in d for d in dicts) + + +def test_load_agent_selection_no_file(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + assert registry.load_agent_selection(agent_dir) == [] + + +@pytest.mark.parametrize( + "field, bad_value", + [ + ("include", "jira"), + ("tags", "pm"), + ("exclude", "jira"), + ("profile", ["all"]), + ("max_tools", "50"), + ("versions", ["1.0.0"]), + ], +) +def test_load_agent_selection_rejects_wrong_types(tmp_path: Path, field, bad_value): + """Fields with wrong JSON types are dropped with a warning, not silently misused.""" + registry = _setup_registry_with_servers(tmp_path) + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "mcp_registry.json").write_text(json.dumps({field: bad_value})) + configs = registry.load_agent_selection(agent_dir) + # All bad fields are dropped, so resolve_for_agent gets no criteria and returns [] + assert configs == [] + + +# ── run_health_check ──────────────────────────────────────────────── + + +def test_run_health_check_healthy(tmp_path: Path, monkeypatch): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="db", transport="http", url="http://localhost:9090") + + class MockClient: + def __init__(self, config): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def list_tools(self): + return [] + + monkeypatch.setattr("framework.runner.mcp_registry.MCPClient", MockClient) + result = registry.run_health_check("db") + assert result["status"] == "healthy" + assert registry._read_installed()["servers"]["db"]["last_health_check_at"] is not None + + +def test_health_check_public_api(tmp_path: Path, monkeypatch): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="db", transport="http", url="http://localhost:9090") + + class MockClient: + def __init__(self, config): + self.config = config + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def list_tools(self): + return [] + + monkeypatch.setattr("framework.runner.mcp_registry.MCPClient", MockClient) + result = registry.health_check("db") + assert result["status"] == "healthy" + + +def test_health_check_prefers_pooled_connection(tmp_path: Path, monkeypatch): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="db", transport="http", url="http://localhost:9090") + + class FakePooledClient: + def list_tools(self): + return [object(), object()] + + class FakeManager: + def __init__(self): + self.acquire_calls = 0 + self.release_calls = 0 + + def has_connection(self, server_name: str) -> bool: + return server_name == "db" + + def health_check(self, server_name: str) -> bool: + return server_name == "db" + + def acquire(self, config): + self.acquire_calls += 1 + return FakePooledClient() + + def release(self, server_name: str) -> None: + self.release_calls += 1 + + fake_manager = FakeManager() + monkeypatch.setattr( + "framework.runner.mcp_registry.MCPConnectionManager.get_instance", + lambda: fake_manager, + ) + + class UnexpectedClient: + def __init__(self, config): + raise AssertionError("fresh MCPClient should not be constructed") + + monkeypatch.setattr("framework.runner.mcp_registry.MCPClient", UnexpectedClient) + + result = registry.health_check("db") + assert result["status"] == "healthy" + assert result["tools"] == 2 + assert fake_manager.acquire_calls == 1 + assert fake_manager.release_calls == 1 + + +def test_health_check_uses_installed_transport_preference(tmp_path: Path, monkeypatch): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local( + name="api", + manifest={ + "transport": {"supported": ["stdio", "http"], "default": "stdio"}, + "stdio": {"command": "uvx", "args": ["legacy-server"]}, + "http": {"url": "http://localhost:9001"}, + }, + transport="http", + ) + + seen_transport: list[str] = [] + + class MockClient: + def __init__(self, config): + seen_transport.append(config.transport) + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def list_tools(self): + return [] + + monkeypatch.setattr("framework.runner.mcp_registry.MCPClient", MockClient) + result = registry.health_check("api") + assert result["status"] == "healthy" + assert seen_transport == ["http"] + + +def test_run_health_check_unhealthy(tmp_path: Path, monkeypatch): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="db", transport="http", url="http://localhost:9999") + + class MockClient: + def __init__(self, config): + pass + + def __enter__(self): + raise ConnectionError("refused") + + def __exit__(self, *a): + pass + + monkeypatch.setattr("framework.runner.mcp_registry.MCPClient", MockClient) + result = registry.run_health_check("db") + assert result["status"] == "unhealthy" + assert "refused" in result["error"] + + +def test_run_health_check_list_tools_failure(tmp_path: Path, monkeypatch): + """Health check where connect succeeds but list_tools fails.""" + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="db", transport="http", url="http://localhost:9090") + + class MockClient: + def __init__(self, config): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def list_tools(self): + raise RuntimeError("tools discovery failed") + + monkeypatch.setattr("framework.runner.mcp_registry.MCPClient", MockClient) + result = registry.run_health_check("db") + assert result["status"] == "unhealthy" + assert "tools discovery failed" in result["error"] + + +def test_run_health_check_unsupported_transport(tmp_path: Path): + """Health check on server with unsupported transport returns unhealthy.""" + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + data = registry._read_installed() + data["servers"]["weird"] = { + "source": "local", + "transport": "grpc", + "enabled": True, + "manifest": {"name": "weird", "transport": {"default": "grpc"}}, + "overrides": {"env": {}, "headers": {}}, + "last_health_check_at": None, + "last_health_status": None, + "last_error": None, + "last_used_at": None, + "last_validated_with_hive_version": None, + } + registry._write_installed(data) + result = registry.run_health_check("weird") + assert result["status"] == "unhealthy" + assert "Unsupported transport" in result["error"] + + +def test_run_health_check_not_installed(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="not installed"): + registry.run_health_check("ghost") + + +def test_resolve_for_agent_no_criteria(tmp_path: Path): + """resolve_for_agent with no criteria returns empty list.""" + registry = _setup_registry_with_servers(tmp_path) + configs = registry.resolve_for_agent() + assert configs == [] + + +def test_install_version_pin_no_version_in_manifest(tmp_path: Path): + """install with version should fail if manifest has no version field.""" + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", {"noversion": {"name": "noversion", "transport": {"default": "stdio"}}} + ) + with pytest.raises(ValueError, match="no version field"): + registry.install("noversion", version="1.0.0") + + +# ── Scope gap fixes ───────────────────────────────────────────────── + + +def test_add_local_with_inline_manifest(tmp_path: Path): + """add_local with manifest dict should register the server directly.""" + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + + manifest = { + "description": "Custom Jira server", + "transport": {"supported": ["stdio"], "default": "stdio"}, + "stdio": {"command": "uvx", "args": ["jira-mcp"], "env": {"TOKEN": "abc"}}, + "tags": ["pm"], + } + registry.add_local(name="jira", manifest=manifest) + + entry = registry._read_installed()["servers"]["jira"] + assert entry["source"] == "local" + assert entry["transport"] == "stdio" + assert entry["manifest"]["stdio"]["command"] == "uvx" + assert entry["manifest"]["tags"] == ["pm"] + assert entry["manifest"]["name"] == "jira" + + +def test_add_local_manifest_without_transport_defaults(tmp_path: Path): + """add_local with manifest but no transport key should default to stdio.""" + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + + manifest = {"stdio": {"command": "echo"}} + registry.add_local(name="simple", manifest=manifest) + + entry = registry._read_installed()["servers"]["simple"] + assert entry["transport"] == "stdio" + + +def test_add_local_requires_transport_without_manifest(tmp_path: Path): + """add_local without manifest or transport should raise.""" + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="transport is required"): + registry.add_local(name="broken") + + +def test_install_with_transport_override(tmp_path: Path): + """install with transport should override the manifest default.""" + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": { + "name": "jira", + "version": "1.0.0", + "transport": {"supported": ["stdio", "http"], "default": "stdio"}, + "stdio": {"command": "uvx"}, + "http": {"url": "http://localhost:4010"}, + } + }, + ) + + registry.install("jira", transport="http") + entry = registry._read_installed()["servers"]["jira"] + assert entry["transport"] == "http" + config = registry.resolve_for_agent(include=["jira"])[0] + assert config.transport == "http" + assert config.url == "http://localhost:4010" + + +def test_install_with_unsupported_transport_raises(tmp_path: Path): + """install with transport not in supported list should raise.""" + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": { + "name": "jira", + "version": "1.0.0", + "transport": {"supported": ["stdio"], "default": "stdio"}, + } + }, + ) + + with pytest.raises(ValueError, match="not supported"): + registry.install("jira", transport="http") + + +def test_install_registry_entry_uses_updated_cached_manifest(tmp_path: Path): + """Registry installs should resolve via the current cached index.""" + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": { + "name": "jira", + "version": "1.0.0", + "transport": {"supported": ["stdio"], "default": "stdio"}, + "stdio": {"command": "uvx", "args": ["jira-v1"]}, + } + }, + ) + + registry.install("jira") + + _write_mock_index( + base / "cache", + { + "jira": { + "name": "jira", + "version": "1.1.0", + "transport": {"supported": ["stdio"], "default": "stdio"}, + "stdio": {"command": "uvx", "args": ["jira-v2"]}, + } + }, + ) + + config = registry.resolve_for_agent(include=["jira"])[0] + assert config.transport == "stdio" + assert config.args == ["jira-v2"] + + +def test_install_registry_entry_resolves_without_cache(tmp_path: Path): + """Registry installs should remain usable even when the cache is missing.""" + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": { + "name": "jira", + "version": "1.0.0", + "transport": {"supported": ["stdio"], "default": "stdio"}, + "stdio": {"command": "uvx", "args": ["jira-mcp"]}, + } + }, + ) + + registry.install("jira") + (base / "cache" / "registry_index.json").unlink() + + config = registry.resolve_for_agent(include=["jira"])[0] + assert config.transport == "stdio" + assert config.command == "uvx" + assert config.args == ["jira-mcp"] + + +def test_run_health_check_all_servers(tmp_path: Path, monkeypatch): + """run_health_check with no name should check all servers.""" + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + registry.add_local(name="a", transport="http", url="http://localhost:1") + registry.add_local(name="b", transport="http", url="http://localhost:2") + + class MockClient: + def __init__(self, config): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def list_tools(self): + return [] + + monkeypatch.setattr("framework.runner.mcp_registry.MCPClient", MockClient) + + results = registry.run_health_check() + assert isinstance(results, dict) + assert "a" in results + assert "b" in results + assert results["a"]["status"] == "healthy" + assert results["b"]["status"] == "healthy" + + +# ── Edge case coverage ────────────────────────────────────────────── + + +def test_add_local_sse_requires_url(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="url is required"): + registry.add_local(name="x", transport="sse") + + +def test_enable_nonexistent_raises(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="not installed"): + registry.enable("ghost") + + +def test_disable_nonexistent_raises(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="not installed"): + registry.disable("ghost") + + +def test_list_installed_empty(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + assert registry.list_installed() == [] + + +def test_list_available_empty_index(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + assert registry.list_available() == [] + + +def test_set_override_nonexistent_raises(tmp_path: Path): + registry = MCPRegistry(base_path=tmp_path / "mcp_registry") + registry.initialize() + with pytest.raises(ValueError, match="not installed"): + registry.set_override("ghost", "KEY", "val") + + +def test_search_no_results(tmp_path: Path): + base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=base) + registry.initialize() + _write_mock_index( + base / "cache", + { + "jira": {"name": "jira", "description": "Issues", "tags": []}, + }, + ) + assert registry.search("nonexistent_query_xyz") == [] + + +def test_resolve_profile_matches_nothing(tmp_path: Path): + registry = _setup_registry_with_servers(tmp_path) + configs = registry.resolve_for_agent(profile="nonexistent_profile") + assert configs == [] + + +# ── _get_hive_version ──────────────────────────────────────────────── + + +def test_get_hive_version_section_aware(tmp_path: Path, monkeypatch): + """Version must come from [project], not from a [tool.*] section.""" + from importlib.metadata import PackageNotFoundError + + import framework.runner.mcp_registry as mod + + # Create directory structure so parents[2] of fake file -> tmp_path + runner_dir = tmp_path / "framework" / "runner" + runner_dir.mkdir(parents=True) + fake_file = runner_dir / "mcp_registry.py" + fake_file.touch() + + # Put version in [tool.*] before [project] to trigger the old bug + toml_content = ( + '[tool.something]\nversion = "9.9.9"\n\n[project]\nname = "framework"\nversion = "0.7.1"\n' + ) + (tmp_path / "pyproject.toml").write_text(toml_content, encoding="utf-8") + + monkeypatch.setattr( + "framework.runner.mcp_registry.version", + lambda _pkg: (_ for _ in ()).throw(PackageNotFoundError()), + ) + monkeypatch.setattr(mod, "__file__", str(fake_file)) + + assert MCPRegistry._get_hive_version() == "0.7.1" + + +def test_get_hive_version_missing_toml(tmp_path: Path, monkeypatch): + """Returns 'unknown' when pyproject.toml does not exist.""" + from importlib.metadata import PackageNotFoundError + + import framework.runner.mcp_registry as mod + + runner_dir = tmp_path / "framework" / "runner" + runner_dir.mkdir(parents=True) + fake_file = runner_dir / "mcp_registry.py" + fake_file.touch() + + monkeypatch.setattr( + "framework.runner.mcp_registry.version", + lambda _pkg: (_ for _ in ()).throw(PackageNotFoundError()), + ) + monkeypatch.setattr(mod, "__file__", str(fake_file)) + + assert MCPRegistry._get_hive_version() == "unknown" diff --git a/core/tests/test_mcp_registry_cli.py b/core/tests/test_mcp_registry_cli.py new file mode 100644 index 0000000000..b1d5c5a82b --- /dev/null +++ b/core/tests/test_mcp_registry_cli.py @@ -0,0 +1,1331 @@ +"""Integration tests for hive mcp CLI commands.""" + +from __future__ import annotations + +import json +from io import StringIO +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from framework.runner.mcp_registry_cli import ( + _parse_key_value_pairs, + _print_security_notice_if_first_use, + cmd_mcp_add, + cmd_mcp_config, + cmd_mcp_disable, + cmd_mcp_enable, + cmd_mcp_health, + cmd_mcp_info, + cmd_mcp_install, + cmd_mcp_list, + cmd_mcp_remove, + cmd_mcp_search, + cmd_mcp_update, + register_mcp_commands, +) + +# ── Fixtures ──────────────────────────────────────────────────────── + + +@pytest.fixture() +def registry_home(tmp_path, monkeypatch): + """Set up an isolated registry base directory.""" + base = tmp_path / "mcp_registry" + base.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + return base + + +@pytest.fixture() +def registry(registry_home): + """Return an initialized MCPRegistry backed by tmp_path.""" + from framework.runner.mcp_registry import MCPRegistry + + reg = MCPRegistry(base_path=registry_home) + reg.initialize() + return reg + + +@pytest.fixture() +def _patch_get_registry(registry, monkeypatch): + """Patch _get_registry so all CLI commands use the test registry.""" + monkeypatch.setattr( + "framework.runner.mcp_registry_cli._get_registry", + lambda base_path=None: registry, + ) + + +@pytest.fixture() +def sample_index(registry_home): + """Write a sample registry index with two servers.""" + index = { + "servers": { + "jira": { + "version": "1.2.0", + "description": "Jira issue tracker integration", + "status": "verified", + "transport": {"supported": ["stdio", "http"], "default": "stdio"}, + "stdio": {"command": "uvx", "args": ["jira-mcp"]}, + "http": {"url": "http://localhost:4010"}, + "tools": [ + {"name": "jira_create_issue", "description": "Create a Jira issue"}, + {"name": "jira_search", "description": "Search issues with JQL"}, + ], + "credentials": [ + { + "id": "api_token", + "env_var": "JIRA_API_TOKEN", + "description": "Jira API token", + "help_url": "https://id.atlassian.com/manage-profile/security/api-tokens", + "required": True, + }, + ], + "tags": ["project-management", "atlassian"], + "hive": {"profiles": ["productivity"], "min_version": "0.5.0"}, + }, + "slack": { + "version": "2.0.0", + "description": "Slack messaging integration", + "status": "community", + "transport": {"supported": ["http"], "default": "http"}, + "http": {"url": "http://localhost:4011"}, + "tools": [{"name": "send_message", "description": "Send a Slack message"}], + "tags": ["messaging"], + }, + } + } + cache_dir = registry_home / "cache" + cache_dir.mkdir(exist_ok=True) + (cache_dir / "registry_index.json").write_text(json.dumps(index), encoding="utf-8") + # Mark as fresh so auto-refresh doesn't trigger + (cache_dir / "last_fetched").write_text( + json.dumps({"timestamp": "2099-01-01T00:00:00+00:00"}), + encoding="utf-8", + ) + return index + + +def _capture(func, args_ns) -> tuple[int, str, str]: + """Call a command handler, capturing stdout and stderr.""" + out, err = StringIO(), StringIO() + with patch("sys.stdout", out), patch("sys.stderr", err): + rc = func(args_ns) + return rc, out.getvalue(), err.getvalue() + + +# ── argparse registration ────────────────────────────────────────── + + +def test_register_mcp_commands_creates_all_subcommands(capsys): + """register_mcp_commands wires all 11 subcommands into argparse.""" + import argparse + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + register_mcp_commands(subparsers) + + expected = [ + "install", + "add", + "remove", + "enable", + "disable", + "list", + "info", + "config", + "search", + "health", + "update", + ] + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args(["mcp", "--help"]) + assert exc_info.value.code == 0 + + help_text = capsys.readouterr().out + for cmd in expected: + assert cmd in help_text, f"subcommand '{cmd}' missing from mcp --help" + + +def test_argparse_round_trip_install(): + """Verify argparse parses 'mcp install jira --version 1.0' correctly.""" + import argparse + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + register_mcp_commands(subparsers) + + args = parser.parse_args(["mcp", "install", "jira", "--version", "1.0"]) + assert args.name == "jira" + assert args.version == "1.0" + assert hasattr(args, "func") + + +# ── install ──────────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_install_writes_to_installed_json(registry, sample_index, monkeypatch): + monkeypatch.setattr("builtins.input", lambda prompt: "") + args = SimpleNamespace(name="jira", version=None, transport=None) + rc, out, err = _capture(cmd_mcp_install, args) + + assert rc == 0 + assert "Installed jira" in out + server = registry.get_server("jira") + assert server is not None + assert server["transport"] == "stdio" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_install_with_version_pin(registry, sample_index, monkeypatch): + monkeypatch.setattr("builtins.input", lambda prompt: "") + args = SimpleNamespace(name="jira", version="1.2.0", transport=None) + rc, out, _ = _capture(cmd_mcp_install, args) + + assert rc == 0 + server = registry.get_server("jira") + assert server["pinned"] is True + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_install_not_found_fails(registry, sample_index): + args = SimpleNamespace(name="nonexistent", version=None, transport=None) + rc, _, err = _capture(cmd_mcp_install, args) + + assert rc == 1 + assert "not found in registry" in err + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_install_duplicate_fails(registry, sample_index): + registry.install("jira") + args = SimpleNamespace(name="jira", version=None, transport=None) + rc, _, err = _capture(cmd_mcp_install, args) + + assert rc == 1 + assert "already exists" in err + + +# ── security notice ──────────────────────────────────────────────── + + +def test_security_notice_shown_only_once(registry_home): + from framework.runner.mcp_registry_cli import _mark_security_notice_shown + + err1, err2 = StringIO(), StringIO() + with patch("sys.stderr", err1): + _print_security_notice_if_first_use(registry_home) + # Simulate successful install persisting the sentinel + _mark_security_notice_shown(registry_home) + with patch("sys.stderr", err2): + _print_security_notice_if_first_use(registry_home) + + assert "Registry servers run code" in err1.getvalue() + assert err2.getvalue() == "" + + +# ── credential prompting ────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_credential_prompt_stores_override(registry, sample_index, monkeypatch): + """Installing a server with required credentials prompts and stores them.""" + registry.install("jira") + + # Simulate user typing a credential value + monkeypatch.setattr("builtins.input", lambda prompt: "my-secret-token") + # Ensure the env var isn't already set + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + + from framework.runner.mcp_registry_cli import _prompt_for_missing_credentials + + manifest = sample_index["servers"]["jira"] + _prompt_for_missing_credentials(registry, "jira", manifest) + + server = registry.get_server("jira") + assert server["overrides"]["env"]["JIRA_API_TOKEN"] == "my-secret-token" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_credential_prompt_skips_when_env_set(registry, sample_index, monkeypatch): + """Don't prompt when the env var is already set.""" + registry.install("jira") + monkeypatch.setenv("JIRA_API_TOKEN", "already-set") + + calls = [] + monkeypatch.setattr("builtins.input", lambda prompt: calls.append(prompt) or "") + + from framework.runner.mcp_registry_cli import _prompt_for_missing_credentials + + manifest = sample_index["servers"]["jira"] + _prompt_for_missing_credentials(registry, "jira", manifest) + + assert len(calls) == 0 + + +# ── add ──────────────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_add_registers_local_http_server(registry): + args = SimpleNamespace( + name="my-db", + transport="http", + url="http://localhost:9090", + command=None, + args=None, + socket_path=None, + description="Custom DB", + from_manifest=None, + ) + rc, out, _ = _capture(cmd_mcp_add, args) + + assert rc == 0 + assert "Registered my-db" in out + server = registry.get_server("my-db") + assert server is not None + assert server["transport"] == "http" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_add_missing_name_fails(registry): + args = SimpleNamespace( + name=None, + transport="http", + url="http://localhost:9090", + command=None, + args=None, + socket_path=None, + description="", + from_manifest=None, + ) + rc, _, err = _capture(cmd_mcp_add, args) + + assert rc == 1 + assert "--name is required" in err + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_add_missing_transport_fails(registry): + args = SimpleNamespace( + name="my-db", + transport=None, + url=None, + command=None, + args=None, + socket_path=None, + description="", + from_manifest=None, + ) + rc, _, err = _capture(cmd_mcp_add, args) + + assert rc == 1 + assert "--transport is required" in err + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_add_from_manifest_file(registry, tmp_path): + manifest = { + "name": "from-file", + "description": "Loaded from manifest", + "transport": {"supported": ["http"], "default": "http"}, + "http": {"url": "http://localhost:5000"}, + } + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + args = SimpleNamespace( + name=None, + transport=None, + url=None, + command=None, + args=None, + socket_path=None, + description="", + from_manifest=str(manifest_path), + ) + rc, out, _ = _capture(cmd_mcp_add, args) + + assert rc == 0 + assert "Registered from-file" in out + server = registry.get_server("from-file") + assert server is not None + + +# ── remove ───────────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_remove_deletes_server(registry, sample_index): + registry.install("jira") + args = SimpleNamespace(name="jira") + rc, out, _ = _capture(cmd_mcp_remove, args) + + assert rc == 0 + assert "Removed jira" in out + assert registry.get_server("jira") is None + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_remove_nonexistent_fails(registry): + args = SimpleNamespace(name="nonexistent") + rc, _, err = _capture(cmd_mcp_remove, args) + + assert rc == 1 + assert "not installed" in err + + +# ── enable / disable ────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_enable_disable_toggles_state(registry, sample_index): + registry.install("jira") + + rc, out, _ = _capture(cmd_mcp_disable, SimpleNamespace(name="jira")) + assert rc == 0 + assert "Disabled" in out + assert registry.get_server("jira")["enabled"] is False + + rc, out, _ = _capture(cmd_mcp_enable, SimpleNamespace(name="jira")) + assert rc == 0 + assert "Enabled" in out + assert registry.get_server("jira")["enabled"] is True + + +# ── list ─────────────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_list_renders_installed_entries(registry, sample_index): + registry.install("jira") + registry.install("slack") + + args = SimpleNamespace(available=False, output_json=False) + rc, out, _ = _capture(cmd_mcp_list, args) + + assert rc == 0 + assert "jira" in out + assert "slack" in out + assert "NAME" in out # header + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_list_empty_shows_help(registry): + args = SimpleNamespace(available=False, output_json=False) + rc, out, _ = _capture(cmd_mcp_list, args) + + assert rc == 0 + assert "No servers installed" in out + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_list_json_output(registry, sample_index): + registry.install("jira") + + args = SimpleNamespace(available=False, output_json=True) + rc, out, _ = _capture(cmd_mcp_list, args) + + assert rc == 0 + data = json.loads(out) + assert isinstance(data, list) + assert data[0]["name"] == "jira" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_list_available_reads_index(registry, sample_index): + args = SimpleNamespace(available=True, output_json=False) + rc, out, _ = _capture(cmd_mcp_list, args) + + assert rc == 0 + assert "jira" in out + assert "slack" in out + + +# ── info ─────────────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_info_shows_details(registry, sample_index): + registry.install("jira") + + args = SimpleNamespace(name="jira", output_json=False) + rc, out, _ = _capture(cmd_mcp_info, args) + + assert rc == 0 + assert "jira" in out + assert "stdio" in out + assert "1.2.0" in out + assert "verified" in out + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_info_json_output(registry, sample_index): + registry.install("jira") + + args = SimpleNamespace(name="jira", output_json=True) + rc, out, _ = _capture(cmd_mcp_info, args) + + assert rc == 0 + data = json.loads(out) + assert data["name"] == "jira" + assert data["transport"] == "stdio" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_info_not_found_fails(registry): + args = SimpleNamespace(name="nonexistent", output_json=False) + rc, _, err = _capture(cmd_mcp_info, args) + + assert rc == 1 + assert "not installed" in err + assert "hive mcp install" in err + + +# ── config ───────────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_config_sets_env_override(registry, sample_index): + registry.install("jira") + + args = SimpleNamespace(name="jira", set_env=["JIRA_API_TOKEN=abc123"], set_header=None) + rc, out, _ = _capture(cmd_mcp_config, args) + + assert rc == 0 + server = registry.get_server("jira") + assert server["overrides"]["env"]["JIRA_API_TOKEN"] == "abc123" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_config_sets_header_override(registry, sample_index): + registry.install("jira") + + args = SimpleNamespace(name="jira", set_env=None, set_header=["Authorization=Bearer xyz"]) + rc, out, _ = _capture(cmd_mcp_config, args) + + assert rc == 0 + server = registry.get_server("jira") + assert server["overrides"]["headers"]["Authorization"] == "Bearer xyz" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_config_shows_current_when_no_args(registry, sample_index): + registry.install("jira") + registry.set_override("jira", "JIRA_API_TOKEN", "secret", override_type="env") + + args = SimpleNamespace(name="jira", set_env=None, set_header=None) + rc, out, _ = _capture(cmd_mcp_config, args) + + assert rc == 0 + assert "JIRA_API_TOKEN" in out + + +# ── search ───────────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_search_finds_by_name(registry, sample_index): + args = SimpleNamespace(query="jira", output_json=False) + rc, out, _ = _capture(cmd_mcp_search, args) + + assert rc == 0 + assert "jira" in out + assert "slack" not in out + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_search_finds_by_tag(registry, sample_index): + args = SimpleNamespace(query="messaging", output_json=False) + rc, out, _ = _capture(cmd_mcp_search, args) + + assert rc == 0 + assert "slack" in out + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_search_no_results(registry, sample_index): + args = SimpleNamespace(query="zzz_nonexistent_zzz", output_json=False) + rc, out, _ = _capture(cmd_mcp_search, args) + + assert rc == 0 + assert "No servers matching" in out + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_search_json_output(registry, sample_index): + args = SimpleNamespace(query="jira", output_json=True) + rc, out, _ = _capture(cmd_mcp_search, args) + + assert rc == 0 + data = json.loads(out) + assert len(data) == 1 + assert data[0]["name"] == "jira" + + +# ── health ───────────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_health_returns_status(registry, sample_index, monkeypatch): + registry.install("jira") + + # Mock health_check to avoid real connections + monkeypatch.setattr( + registry, + "health_check", + lambda name=None: {"name": "jira", "status": "healthy", "tools": 2, "error": None}, + ) + + args = SimpleNamespace(name="jira", output_json=False) + rc, out, _ = _capture(cmd_mcp_health, args) + + assert rc == 0 + assert "healthy" in out + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_health_json_output(registry, sample_index, monkeypatch): + registry.install("jira") + + monkeypatch.setattr( + registry, + "health_check", + lambda name=None: {"name": "jira", "status": "healthy", "tools": 2, "error": None}, + ) + + args = SimpleNamespace(name="jira", output_json=True) + rc, out, _ = _capture(cmd_mcp_health, args) + + assert rc == 0 + data = json.loads(out) + assert "jira" in data + + +# ── update ───────────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_update_named_server_reinstalls(registry, sample_index): + """update removes and reinstalls, preserving overrides.""" + registry.install("jira") + registry.set_override("jira", "JIRA_API_TOKEN", "my-token", override_type="env") + + args = SimpleNamespace(name="jira") + rc, out, _ = _capture(cmd_mcp_update, args) + + assert rc == 0 + assert "jira" in out + server = registry.get_server("jira") + assert server is not None + # Overrides preserved + assert server["overrides"]["env"]["JIRA_API_TOKEN"] == "my-token" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_update_local_server_fails(registry): + """update rejects local servers.""" + registry.add_local(name="my-db", transport="http", url="http://localhost:9090") + + args = SimpleNamespace(name="my-db") + rc, _, err = _capture(cmd_mcp_update, args) + + assert rc == 1 + assert "local server" in err + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_update_pinned_server_fails_with_correct_remediation(registry, sample_index): + """update rejects pinned servers with remove+install remediation, not config.""" + registry.install("jira", version="1.2.0") + + args = SimpleNamespace(name="jira") + rc, _, err = _capture(cmd_mcp_update, args) + + assert rc == 1 + assert "pinned" in err + assert "hive mcp remove" in err + assert "hive mcp install" in err + # Must NOT suggest config --set pinned=false (config only writes env/header overrides) + assert "config" not in err + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_update_restores_server_on_reinstall_failure(registry, sample_index): + """update restores the original entry if reinstall fails.""" + registry.install("jira") + registry.set_override("jira", "JIRA_API_TOKEN", "my-token", override_type="env") + + # Remove jira from the cached index so install() will fail after remove() + cache_dir = registry._cache_dir + index = json.loads((cache_dir / "registry_index.json").read_text(encoding="utf-8")) + del index["servers"]["jira"] + (cache_dir / "registry_index.json").write_text(json.dumps(index), encoding="utf-8") + + args = SimpleNamespace(name="jira") + rc, _, err = _capture(cmd_mcp_update, args) + + assert rc == 1 + assert "restored" in err + + # Server must still be installed with original data + server = registry.get_server("jira") + assert server is not None + assert server["transport"] == "stdio" + assert server["overrides"]["env"]["JIRA_API_TOKEN"] == "my-token" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_update_index_succeeds(registry, monkeypatch): + monkeypatch.setattr(registry, "update_index", lambda: 5) + + args = SimpleNamespace(name=None) + rc, out, _ = _capture(cmd_mcp_update, args) + + assert rc == 0 + assert "5 servers available" in out + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_update_all_updates_registry_servers(registry, sample_index, monkeypatch): + """update (no name) refreshes index and updates all installed registry servers.""" + registry.install("jira") + registry.install("slack") + registry.set_override("jira", "JIRA_API_TOKEN", "keep-me", override_type="env") + + # Mock update_index so it doesn't hit the network + monkeypatch.setattr(registry, "update_index", lambda: 2) + + args = SimpleNamespace(name=None) + rc, out, _ = _capture(cmd_mcp_update, args) + + assert rc == 0 + assert "2 installed server(s)" in out + assert "jira" in out + assert "slack" in out + # Overrides preserved + server = registry.get_server("jira") + assert server["overrides"]["env"]["JIRA_API_TOKEN"] == "keep-me" + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_update_all_skips_local_and_pinned(registry, sample_index, monkeypatch): + """update (no name) skips local servers and pinned servers.""" + registry.install("jira", version="1.2.0") # pinned + registry.add_local(name="my-db", transport="http", url="http://localhost:9090") + + monkeypatch.setattr(registry, "update_index", lambda: 2) + + args = SimpleNamespace(name=None) + rc, out, _ = _capture(cmd_mcp_update, args) + + assert rc == 0 + # Neither should appear in update output (both skipped) + assert "installed server(s)" not in out + + +# ── parse helpers ────────────────────────────────────────────────── + + +def test_parse_key_value_pairs_valid(): + result = _parse_key_value_pairs(["KEY=value", "FOO=bar=baz"]) + assert result == {"KEY": "value", "FOO": "bar=baz"} + + +def test_parse_key_value_pairs_invalid(): + with pytest.raises(ValueError, match="Invalid format"): + _parse_key_value_pairs(["NOEQUALS"]) + + +def test_parse_key_value_pairs_empty_key(): + with pytest.raises(ValueError, match="Key cannot be empty"): + _parse_key_value_pairs(["=value"]) + + +# ── index refresh failure semantics ─────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_install_fails_when_no_cache_and_refresh_fails(registry, monkeypatch): + """install hard-fails when there's no cached index and refresh fails.""" + import httpx + + monkeypatch.setattr(registry, "is_index_stale", lambda: True) + monkeypatch.setattr(registry, "update_index", _raise(httpx.ConnectError("offline"))) + monkeypatch.setattr("builtins.input", lambda prompt: "") + + args = SimpleNamespace(name="jira", version=None, transport=None) + rc, _, err = _capture(cmd_mcp_install, args) + + assert rc == 1 + assert "no registry index available" in err + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_install_uses_stale_cache_when_refresh_fails(registry, sample_index, monkeypatch): + """install warns but continues with stale cache when refresh fails.""" + import httpx + + monkeypatch.setattr(registry, "is_index_stale", lambda: True) + monkeypatch.setattr(registry, "update_index", _raise(httpx.ConnectError("offline"))) + monkeypatch.setattr("builtins.input", lambda prompt: "") + + args = SimpleNamespace(name="jira", version=None, transport=None) + rc, out, err = _capture(cmd_mcp_install, args) + + assert rc == 0 + assert "Using cached index" in err + assert "Installed jira" in out + + +# ── list columns ────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_list_includes_tools_count_and_trust_tier(registry, sample_index): + """list table includes TOOLS and TRUST columns.""" + registry.install("jira") + + args = SimpleNamespace(available=False, output_json=False) + rc, out, _ = _capture(cmd_mcp_list, args) + + assert rc == 0 + assert "TOOLS" in out + assert "TRUST" in out + # jira has 2 tools and "verified" status + assert "2" in out + assert "verified" in out + + +# ── config masking ──────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_config_display_masks_values(registry, sample_index): + """config display shows not actual values.""" + registry.install("jira") + registry.set_override("jira", "JIRA_API_TOKEN", "super-secret-value", override_type="env") + + args = SimpleNamespace(name="jira", set_env=None, set_header=None) + rc, out, _ = _capture(cmd_mcp_config, args) + + assert rc == 0 + assert "" in out + assert "super-secret-value" not in out + assert "supe..." not in out + + +# ── info masking ────────────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_info_masks_override_values(registry, sample_index): + """info display shows not actual secret values.""" + registry.install("jira") + registry.set_override("jira", "JIRA_API_TOKEN", "my-secret", override_type="env") + + args = SimpleNamespace(name="jira", output_json=False) + rc, out, _ = _capture(cmd_mcp_info, args) + + assert rc == 0 + assert "" in out + assert "my-secret" not in out + + +# ── credential prompting cancel ─────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_credential_prompt_cancel_does_not_abort_install(registry, sample_index, monkeypatch): + """Ctrl+C during credential prompting doesn't abort the install itself.""" + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setattr("builtins.input", _raise(KeyboardInterrupt())) + + # Install first, then test prompting separately + registry.install("jira") + + from framework.runner.mcp_registry_cli import _prompt_for_missing_credentials + + manifest = sample_index["servers"]["jira"] + # Should not raise + _prompt_for_missing_credentials(registry, "jira", manifest) + + # Server still installed, no override set + server = registry.get_server("jira") + assert server is not None + assert server["overrides"]["env"] == {} + + +# ── update DX-2 message ────────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_update_nonexistent_server_fails(registry): + """update returns DX-2 error when server not installed.""" + args = SimpleNamespace(name="nonexistent") + rc, _, err = _capture(cmd_mcp_update, args) + + assert rc == 1 + assert "not installed" in err + assert "hive mcp install" in err + + +# ── helper ──────────────────────────────────────────────────────── + + +def _raise(exc): + """Return a callable that raises the given exception.""" + + def _raiser(*args, **kwargs): + raise exc + + return _raiser + + +# ── end-to-end CLI integration via real entrypoint ──────────────── + + +def test_main_dispatches_mcp_list_through_real_argparse(registry_home, monkeypatch): + """hive mcp list goes through main() -> register_mcp_commands -> cmd_mcp_list.""" + from framework.runner.mcp_registry import MCPRegistry + + reg = MCPRegistry(base_path=registry_home) + reg.initialize() + monkeypatch.setattr( + "framework.runner.mcp_registry_cli._get_registry", + lambda base_path=None: reg, + ) + + monkeypatch.setattr("sys.argv", ["hive", "mcp", "list"]) + + from framework.cli import main + + out = StringIO() + with patch("sys.stdout", out), pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + assert "No servers installed" in out.getvalue() + + +def test_main_dispatches_mcp_install_through_real_argparse( + registry_home, sample_index, monkeypatch +): + """hive mcp install jira goes through main() -> real argparse -> cmd_mcp_install.""" + from framework.runner.mcp_registry import MCPRegistry + + reg = MCPRegistry(base_path=registry_home) + reg.initialize() + monkeypatch.setattr( + "framework.runner.mcp_registry_cli._get_registry", + lambda base_path=None: reg, + ) + monkeypatch.setattr("builtins.input", lambda prompt: "") + monkeypatch.setattr("sys.argv", ["hive", "mcp", "install", "jira"]) + + from framework.cli import main + + out = StringIO() + with patch("sys.stdout", out), pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + assert "Installed jira" in out.getvalue() + + +def test_main_dispatches_mcp_update_named_through_real_argparse(registry_home, monkeypatch): + """hive mcp update nonexistent goes through main() and returns error code 1.""" + from framework.runner.mcp_registry import MCPRegistry + + reg = MCPRegistry(base_path=registry_home) + reg.initialize() + monkeypatch.setattr( + "framework.runner.mcp_registry_cli._get_registry", + lambda base_path=None: reg, + ) + monkeypatch.setattr("sys.argv", ["hive", "mcp", "update", "nonexistent"]) + + from framework.cli import main + + err = StringIO() + with patch("sys.stderr", err), pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + assert "not installed" in err.getvalue() + + +# ── info --json includes agent usage ───────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_info_json_includes_agent_usage(registry, sample_index, tmp_path, monkeypatch): + """info --json output includes used_by_agents when agents reference the server.""" + registry.install("jira") + + # Create a fake agent dir with mcp_registry.json that includes jira + agent_dir = tmp_path / "fake_agent" + agent_dir.mkdir() + (agent_dir / "mcp_registry.json").write_text( + json.dumps({"include": ["jira"]}), encoding="utf-8" + ) + + # Patch _find_agents_using_server to use our fake directory + monkeypatch.setattr( + "framework.runner.mcp_registry_cli._find_agents_using_server", + lambda reg, name: [str(agent_dir)], + ) + + args = SimpleNamespace(name="jira", output_json=True) + rc, out, _ = _capture(cmd_mcp_info, args) + + assert rc == 0 + data = json.loads(out) + assert "used_by_agents" in data + assert str(agent_dir) in data["used_by_agents"] + + +# ── info --json masks secret values ────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_info_json_masks_override_secrets(registry, sample_index): + """info --json must not leak actual override values.""" + registry.install("jira") + registry.set_override("jira", "JIRA_API_TOKEN", "super-secret-token-123", override_type="env") + registry.set_override("jira", "Authorization", "Bearer top-secret", override_type="headers") + + args = SimpleNamespace(name="jira", output_json=True) + rc, out, _ = _capture(cmd_mcp_info, args) + + assert rc == 0 + raw_output = out + assert "super-secret-token-123" not in raw_output + assert "top-secret" not in raw_output + + data = json.loads(out) + assert data["overrides"]["env"]["JIRA_API_TOKEN"] == "" + assert data["overrides"]["headers"]["Authorization"] == "" + + +# ── health all-servers path ────────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_health_all_servers(registry, sample_index, monkeypatch): + """health with no name checks all installed servers.""" + registry.install("jira") + registry.install("slack") + + monkeypatch.setattr( + registry, + "health_check", + lambda name=None: { + "jira": {"status": "healthy", "tools": 2, "error": None}, + "slack": {"status": "unhealthy", "tools": 0, "error": "Connection refused"}, + }, + ) + + args = SimpleNamespace(name=None, output_json=False) + rc, out, _ = _capture(cmd_mcp_health, args) + + assert rc == 0 + assert "jira" in out + assert "healthy" in out + assert "slack" in out + assert "Connection refused" in out + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_health_all_servers_json(registry, sample_index, monkeypatch): + """health --json with no name returns dict keyed by server name.""" + registry.install("jira") + + monkeypatch.setattr( + registry, + "health_check", + lambda name=None: { + "jira": {"status": "healthy", "tools": 2, "error": None}, + }, + ) + + args = SimpleNamespace(name=None, output_json=True) + rc, out, _ = _capture(cmd_mcp_health, args) + + assert rc == 0 + data = json.loads(out) + assert "jira" in data + assert data["jira"]["status"] == "healthy" + + +# ── _find_agents_using_server with real files ──────────────────── + + +def test_find_agents_using_server_resolves_via_load_agent_selection( + registry_home, tmp_path, monkeypatch +): + """_find_agents_using_server exercises the real helper with patched candidate dirs.""" + from framework.runner.mcp_registry import MCPRegistry + + reg = MCPRegistry(base_path=registry_home) + reg.initialize() + + # Install a server so resolve_for_agent can find it + cache_dir = registry_home / "cache" + cache_dir.mkdir(exist_ok=True) + index = { + "servers": { + "jira": { + "version": "1.0.0", + "transport": {"supported": ["http"], "default": "http"}, + "http": {"url": "http://localhost:4010"}, + "tools": [], + "tags": ["pm"], + } + } + } + (cache_dir / "registry_index.json").write_text(json.dumps(index), encoding="utf-8") + (cache_dir / "last_fetched").write_text( + json.dumps({"timestamp": "2099-01-01T00:00:00+00:00"}), encoding="utf-8" + ) + reg.install("jira") + + # Create fake agent directories: one that includes jira, one that doesn't + exports_dir = tmp_path / "exports" + exports_dir.mkdir() + agent_yes = exports_dir / "agent_with_jira" + agent_yes.mkdir() + (agent_yes / "mcp_registry.json").write_text( + json.dumps({"include": ["jira"]}), encoding="utf-8" + ) + agent_no = exports_dir / "agent_without_jira" + agent_no.mkdir() + (agent_no / "mcp_registry.json").write_text( + json.dumps({"include": ["slack"]}), encoding="utf-8" + ) + + # Patch the path resolution so the helper scans our tmp_path dirs + import framework.runner.mcp_registry_cli as cli_mod + + def _find_with_tmp_paths(registry, name): + """Run the real helper logic but over tmp_path agent dirs.""" + agent_dirs = [] + for child in exports_dir.iterdir(): + if child.is_dir(): + agent_dirs.append(child) + + matches = [] + for agent_dir in agent_dirs: + registry_json = agent_dir / "mcp_registry.json" + if not registry_json.exists(): + continue + try: + configs = registry.load_agent_selection(agent_dir) + resolved_names = {c["name"] for c in configs} + if name in resolved_names: + matches.append(str(agent_dir)) + except Exception: + continue + return matches + + monkeypatch.setattr(cli_mod, "_find_agents_using_server", _find_with_tmp_paths) + + results = cli_mod._find_agents_using_server(reg, "jira") + assert str(agent_yes) in results + assert str(agent_no) not in results + + +# ── real integration: real registry on disk → CLI commands ──────── + + +def test_integration_real_registry_install_list_info_remove(tmp_path, monkeypatch): + """Integration: real MCPRegistry on disk → install → list → info → config → remove. + + No _get_registry patches. Real JSON files on disk. Same pattern as + Richard's AgentRunner integration test. + """ + # Set credential env var so install doesn't prompt for stdin + monkeypatch.setenv("JIRA_TOKEN", "from-env") + from framework.runner.mcp_registry import MCPRegistry + from framework.runner.mcp_registry_cli import ( + cmd_mcp_config, + cmd_mcp_info, + cmd_mcp_install, + cmd_mcp_list, + cmd_mcp_remove, + ) + + # Set up a real registry with a real cached index + registry_base = tmp_path / "mcp_registry" + registry = MCPRegistry(base_path=registry_base) + registry.initialize() + + cache_dir = registry_base / "cache" + cache_dir.mkdir(exist_ok=True) + index = { + "servers": { + "jira": { + "version": "1.2.0", + "description": "Jira integration", + "status": "verified", + "transport": {"supported": ["stdio", "http"], "default": "http"}, + "http": {"url": "http://localhost:4010"}, + "tools": [ + {"name": "create_issue", "description": "Create a Jira issue"}, + {"name": "search", "description": "Search issues"}, + ], + "credentials": [ + {"env_var": "JIRA_TOKEN", "description": "API token", "required": True}, + ], + "tags": ["pm"], + } + } + } + (cache_dir / "registry_index.json").write_text(json.dumps(index), encoding="utf-8") + (cache_dir / "last_fetched").write_text( + json.dumps({"timestamp": "2099-01-01T00:00:00+00:00"}), encoding="utf-8" + ) + # Security notice sentinel so install doesn't prompt + (registry_base / ".security_notice_shown").touch() + + # Patch _get_registry to use our real registry (only to set base_path) + import framework.runner.mcp_registry_cli as cli_mod + + cli_mod_get_registry = cli_mod._get_registry + cli_mod._get_registry = lambda base_path=None: registry + + try: + # 1. Install + rc, out, _ = _capture( + cmd_mcp_install, + SimpleNamespace(name="jira", version=None, transport=None), + ) + assert rc == 0 + assert "Installed jira" in out + + # Verify real file on disk + installed = json.loads((registry_base / "installed.json").read_text(encoding="utf-8")) + assert "jira" in installed["servers"] + assert installed["servers"]["jira"]["transport"] == "http" + assert installed["servers"]["jira"]["source"] == "registry" + + # 2. List — should show jira with tools count and trust tier + rc, out, _ = _capture( + cmd_mcp_list, + SimpleNamespace(available=False, output_json=False), + ) + assert rc == 0 + assert "jira" in out + assert "2" in out # tools count + assert "verified" in out # trust tier + + # 3. Config — set a credential + rc, out, _ = _capture( + cmd_mcp_config, + SimpleNamespace(name="jira", set_env=["JIRA_TOKEN=real-token"], set_header=None), + ) + assert rc == 0 + + # Verify override persisted to disk + installed = json.loads((registry_base / "installed.json").read_text(encoding="utf-8")) + assert installed["servers"]["jira"]["overrides"]["env"]["JIRA_TOKEN"] == "real-token" + + # 4. Info --json — verify enriched output with masked overrides + rc, out, _ = _capture( + cmd_mcp_info, + SimpleNamespace(name="jira", output_json=True), + ) + assert rc == 0 + data = json.loads(out) + assert data["name"] == "jira" + assert data["transport"] == "http" + assert data["manifest_version"] == "1.2.0" + assert data["overrides"]["env"]["JIRA_TOKEN"] == "" + assert "real-token" not in out + + # 5. Remove + rc, out, _ = _capture( + cmd_mcp_remove, + SimpleNamespace(name="jira"), + ) + assert rc == 0 + assert "Removed jira" in out + + # Verify removed from disk + installed = json.loads((registry_base / "installed.json").read_text(encoding="utf-8")) + assert "jira" not in installed["servers"] + + finally: + cli_mod._get_registry = cli_mod_get_registry + + +# ── list --json masks secrets ──────────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_list_json_masks_override_secrets(registry, sample_index): + """list --json must not leak actual override values.""" + registry.install("jira") + registry.set_override("jira", "JIRA_API_TOKEN", "super-secret", override_type="env") + + args = SimpleNamespace(available=False, output_json=True) + rc, out, _ = _capture(cmd_mcp_list, args) + + assert rc == 0 + assert "super-secret" not in out + data = json.loads(out) + assert data[0]["overrides"]["env"]["JIRA_API_TOKEN"] == "" + + +# ── update preserves enabled state ────────────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_update_preserves_disabled_state(registry, sample_index): + """update must not re-enable a disabled server.""" + registry.install("jira") + registry.disable("jira") + assert registry.get_server("jira")["enabled"] is False + + args = SimpleNamespace(name="jira") + rc, out, _ = _capture(cmd_mcp_update, args) + + assert rc == 0 + server = registry.get_server("jira") + assert server["enabled"] is False + + +# ── security notice sentinel after success ─────────────────────── + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_security_notice_not_persisted_on_failed_install(registry, registry_home, sample_index): + """Sentinel must not be written if install fails.""" + sentinel = registry_home / ".security_notice_shown" + assert not sentinel.exists() + + # Install a server that doesn't exist in the index + args = SimpleNamespace(name="nonexistent", version=None, transport=None) + rc, _, _ = _capture(cmd_mcp_install, args) + + assert rc == 1 + assert not sentinel.exists() + + +@pytest.mark.usefixtures("_patch_get_registry") +def test_security_notice_persisted_on_successful_install( + registry, registry_home, sample_index, monkeypatch +): + """Sentinel must be written after a successful install.""" + monkeypatch.setattr("builtins.input", lambda prompt: "") + sentinel = registry_home / ".security_notice_shown" + assert not sentinel.exists() + + args = SimpleNamespace(name="jira", version=None, transport=None) + rc, _, _ = _capture(cmd_mcp_install, args) + + assert rc == 0 + assert sentinel.exists() diff --git a/core/tests/test_runner_cli_frontend.py b/core/tests/test_runner_cli_frontend.py new file mode 100644 index 0000000000..0f67b3caf8 --- /dev/null +++ b/core/tests/test_runner_cli_frontend.py @@ -0,0 +1,60 @@ +"""Tests for frontend build fallback in the runner CLI.""" + +import subprocess + +from framework.runner import cli as runner_cli + + +def _write_frontend_tree(tmp_path, *, with_dist: bool = False): + frontend_dir = tmp_path / "core" / "frontend" + (frontend_dir / "src").mkdir(parents=True) + (frontend_dir / "package.json").write_text("{}", encoding="utf-8") + (frontend_dir / "src" / "main.tsx").write_text("console.log('hi')", encoding="utf-8") + if with_dist: + (frontend_dir / "dist").mkdir() + (frontend_dir / "dist" / "index.html").write_text("", encoding="utf-8") + return frontend_dir + + +def test_build_frontend_handles_text_calledprocesserror(monkeypatch, tmp_path, capsys): + _write_frontend_tree(tmp_path) + monkeypatch.chdir(tmp_path) + + def fake_run(cmd, **kwargs): + raise subprocess.CalledProcessError( + 1, + cmd, + output="npm output", + stderr="vite config failed", + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert runner_cli._build_frontend() is False + + output = capsys.readouterr().out + assert "Frontend build failed while running" in output + assert "vite config failed" in output + + +def test_build_frontend_cleans_cache_and_uses_windows_npm_cmd(monkeypatch, tmp_path): + frontend_dir = _write_frontend_tree(tmp_path) + cache_file = frontend_dir / "tsconfig.app.tsbuildinfo" + cache_file.write_text("stale", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(runner_cli.sys, "platform", "win32") + + commands = [] + + def fake_run(cmd, **kwargs): + commands.append(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert runner_cli._build_frontend() is True + assert not cache_file.exists() + assert commands == [ + ["npm.cmd", "install", "--no-fund", "--no-audit"], + ["npm.cmd", "run", "build"], + ] diff --git a/core/tests/test_tool_registry.py b/core/tests/test_tool_registry.py index fd8b9ffa28..cebf2ee05a 100644 --- a/core/tests/test_tool_registry.py +++ b/core/tests/test_tool_registry.py @@ -6,10 +6,12 @@ could cause a json.JSONDecodeError and crash execution. """ +import logging import textwrap from pathlib import Path from types import SimpleNamespace +from framework.llm.provider import Tool, ToolUse from framework.runner.tool_registry import ToolRegistry @@ -206,3 +208,188 @@ def fake_client_factory(config): registry.cleanup() assert created_clients[0].disconnect_calls == 1 + + +def test_load_registry_servers_retries_when_registration_returns_zero(monkeypatch): + registry = ToolRegistry() + attempts = {"count": 0} + + def fake_register(server_config, use_connection_manager=True): + attempts["count"] += 1 + return 0 if attempts["count"] == 1 else 2 + + monkeypatch.setattr(registry, "register_mcp_server", fake_register) + monkeypatch.setattr("time.sleep", lambda _: None) + + results = registry.load_registry_servers( + [{"name": "jira", "transport": "http", "url": "http://localhost:4010"}], + log_summary=False, + ) + + assert attempts["count"] == 2 + assert results == [ + { + "server": "jira", + "status": "loaded", + "tools_loaded": 2, + "skipped_reason": None, + } + ] + + +def test_load_registry_servers_marks_failures_as_skipped(monkeypatch): + registry = ToolRegistry() + + monkeypatch.setattr(registry, "register_mcp_server", lambda *args, **kwargs: 0) + monkeypatch.setattr("time.sleep", lambda _: None) + + results = registry.load_registry_servers( + [{"name": "jira", "transport": "http", "url": "http://localhost:4010"}], + log_summary=False, + ) + + assert results == [ + { + "server": "jira", + "status": "skipped", + "tools_loaded": 0, + "skipped_reason": "registered 0 tools", + } + ] + + +def test_load_registry_servers_emits_structured_log_fields(monkeypatch): + registry = ToolRegistry() + captured_logs: list[tuple[str, dict | None]] = [] + + monkeypatch.setattr(registry, "register_mcp_server", lambda *args, **kwargs: 2) + monkeypatch.setattr( + "framework.runner.tool_registry.logger.info", + lambda message, *args, **kwargs: captured_logs.append((message, kwargs.get("extra"))), + ) + + registry.load_registry_servers( + [{"name": "jira", "transport": "http", "url": "http://localhost:4010"}], + log_summary=True, + ) + + assert captured_logs == [ + ( + "MCP registry server resolution", + { + "event": "mcp_registry_server_resolution", + "server": "jira", + "status": "loaded", + "tools_loaded": 2, + "skipped_reason": None, + }, + ) + ] + + +def test_tool_execution_error_logs_stack_trace_and_context(caplog): + """ToolRegistry should log stack traces and context when tool execution fails.""" + registry = ToolRegistry() + + def failing_executor(inputs: dict) -> None: + raise ValueError("Intentional test failure") + + tool = Tool( + name="failing_tool", + description="A tool that always fails", + parameters={"type": "object", "properties": {}}, + ) + registry.register("failing_tool", tool, failing_executor) + + tool_use = ToolUse( + id="test_call_123", + name="failing_tool", + input={"param": "value"}, + ) + + with caplog.at_level(logging.ERROR): + executor = registry.get_executor() + result = executor(tool_use) + + assert result.is_error is True + assert "Intentional test failure" in result.content + + assert any("failing_tool" in record.message for record in caplog.records) + assert any("test_call_123" in record.message for record in caplog.records) + assert any(record.exc_info is not None for record in caplog.records) + + +def test_tool_execution_error_logs_inputs(caplog): + """ToolRegistry should log tool inputs when execution fails.""" + registry = ToolRegistry() + + def failing_executor(inputs: dict) -> None: + raise RuntimeError("Tool failed") + + tool = Tool( + name="input_logging_tool", + description="Tests input logging", + parameters={"type": "object", "properties": {"foo": {"type": "string"}}}, + ) + registry.register("input_logging_tool", tool, failing_executor) + + tool_use = ToolUse( + id="call_456", + name="input_logging_tool", + input={"foo": "bar", "nested": {"key": "value"}}, + ) + + with caplog.at_level(logging.ERROR): + executor = registry.get_executor() + executor(tool_use) + + log_messages = [record.message for record in caplog.records] + full_log = " ".join(log_messages) + assert '"foo": "bar"' in full_log or "'foo': 'bar'" in full_log + + +def test_unknown_tool_error_returns_proper_result(): + """ToolRegistry should return proper error for unknown tools.""" + registry = ToolRegistry() + tool_use = ToolUse( + id="unknown_call", + name="nonexistent_tool", + input={}, + ) + + executor = registry.get_executor() + result = executor(tool_use) + + assert result.is_error is True + assert "Unknown tool" in result.content + assert "nonexistent_tool" in result.content + + +def test_tool_execution_error_truncates_large_inputs(caplog): + """ToolRegistry should truncate large inputs in error logs.""" + registry = ToolRegistry() + + def failing_executor(inputs: dict) -> None: + raise RuntimeError("Tool failed") + + tool = Tool( + name="large_input_tool", + description="Tests input truncation", + parameters={"type": "object", "properties": {}}, + ) + registry.register("large_input_tool", tool, failing_executor) + + large_input = {"data": "x" * 1000} + tool_use = ToolUse( + id="call_789", + name="large_input_tool", + input=large_input, + ) + + with caplog.at_level(logging.ERROR): + executor = registry.get_executor() + executor(tool_use) + + log_messages = [record.message for record in caplog.records] + full_log = " ".join(log_messages) + assert "...(truncated)" in full_log diff --git a/docs/Queen Bee Outcome Evaluation - Generation.csv b/docs/Queen Bee Outcome Evaluation - Generation.csv deleted file mode 100644 index a650f25aeb..0000000000 --- a/docs/Queen Bee Outcome Evaluation - Generation.csv +++ /dev/null @@ -1,1415 +0,0 @@ -Question,Entry Point,Request,openai/gpt-5-mini,gemini/gemini-3-flash-preview,anthropic/claude-haiku-4-5,zai/glm-5,Desirable Response,,Optimization Method -What can you do?,Deep Research Agent,,"I can do four main things: -Build agents end-to-end -Design graph (2–4 nodes), prompts, tools, edges -Implement files under exports/{agent_name} -Validate with: -default_agent.validate() -AgentRunner.load(...) -validate_agent_tools(...) -run_agent_tests(...) -Load the built agent into this session immediately -Modify/fix existing agents -Debug load failures, unreachable nodes, bad exports, tool mismatches -Inspect sessions/checkpoints/memory for failing runs -Patch code and re-test until passing -Do general coding tasks directly -Read/edit/write files -Run shell commands/tests -Refactor, add features, fix bugs -Run the research worker when appropriate -It can perform interactive web research with citations and produce reports -I can start it, monitor progress, pass your instructions, and stop it if needed -If you want, give me one concrete task now (build, fix, or research), and I’ll start.",,"What I Can Do -Agent Building & Development -Design agents — Understand your use case, propose architecture, get approval -Implement agents — Write all files (config, nodes, graph, tests) following framework conventions -Validate & test — Run structural validation, tool checks, and pytest suites -Debug agents — Inspect session state, memory, checkpoints, execution logs -Read & modify code — Consult reference agents, fix bugs, refactor -Code & File Work -Read/write/edit files with fuzzy matching -Search codebases with regex -Run shell commands (Python, tests, builds) -Undo changes via git -Agent Lifecycle -List agents — See all built agents and their session counts -Monitor sessions — Check status, memory, checkpoints of running agents -Load agents — Make a built agent available as the worker in this session -Run tests — Execute pytest on agent test suites with structured results -Worker Delegation -Start the worker — Give it a research task; it runs autonomously -Monitor progress — Check status, health, recent steps -Send messages — Inject instructions while it's running -Stop execution — Cancel the worker's current task -Critical Rules I Follow -Read before writing — Never assume code structure; read reference agents first -Verify tools — Always discover available tools dynamically; never guess -Test after building — Validate structure, load, tools, then run tests -Professional defaults — Use framework patterns exactly; no shortcuts -2-4 nodes MAX — Merge thin nodes aggressively to preserve context -What's your next move?","Direct capabilities (I do these myself): - -Category What I can do -File operations Read, write, edit, search, list files anywhere in the project -Shell commands Run any command (tests, scripts, git, etc.) -Agent building Design and implement Hive agents from scratch -Validation Test agent structure, verify tools exist, run pytest -Debugging Inspect sessions, checkpoints, memory for built agents -Code changes Any coding task — bug fixes, features, refactoring -Delegation capability: - -The deep research worker can search the web, scrape sources, and produce cited reports. I delegate to it when you need research on a topic. - -Typical workflows: - -""Build me an agent that..."" → I design (2-4 nodes), implement, validate, and load it into this session -""Debug my failing agent"" → I inspect sessions, checkpoints, memory to find the issue -""Research X topic"" → I start the worker to search and compile findings -""Change this code"" → I read, edit, and test directly -Limits: - -I don't fabricate tools — I verify what exists first -I follow existing patterns, not assumptions -I validate before declaring success -What do you need?","Direct capabilities (I do these myself): - -Category What I can do -File operations Read, write, edit, search, list files anywhere in the project -Shell commands Run any command (tests, scripts, git, etc.) -Agent building Design and implement Hive agents from scratch -Validation Test agent structure, verify tools exist, run pytest -Debugging Inspect sessions, checkpoints, memory for built agents -Code changes Any coding task — bug fixes, features, refactoring -Delegation capability: - -The deep research worker can search the web, scrape sources, and produce cited reports. I delegate to it when you need research on a topic. - -Typical workflows: - -""Build me an agent that..."" → I design (2-4 nodes), implement, validate, and load it into this session -""Debug my failing agent"" → I inspect sessions, checkpoints, memory to find the issue -""Research X topic"" → I start the worker to search and compile findings -""Change this code"" → I read, edit, and test directly -Limits: - -I don't fabricate tools — I verify what exists first -I follow existing patterns, not assumptions -I validate before declaring success -What do you need?",,System Prompt -What can you do?,Passive Vulnerability Assessment,,,"I have two primary roles: as your primary coding interface and as the manager of the Hive framework. - -1. Direct Coding & Agent Building -I can handle any technical task involving the framework or general software development: - -Build New Agents: I can design, implement, and verify custom agents in the exports/ directory. -Modify & Debug: I can read existing code, fix bugs, or expand the capabilities of your current agents. -Framework Management: I can discover tools via MCP, run validation tests, and manage agent sessions. -Direct File Operations: I can read, write, and edit files across the project. -2. Specialized Security Worker -I currently have a Vulnerability Assessment worker loaded and ready. This worker is specialized for: - -Passive Reconnaissance: Non-intrusive scanning of websites (SSL, DNS, Headers, Ports, Tech Stack). -Risk Scoring: Generating A-F letter grades for different security categories. -Remediation Reporting: Providing actionable security guidance for developers. -How would you like to proceed? I can start a security scan for you, or we can work on building/modifying something in the codebase.","I'm a coding agent with deep Hive framework integration. Here's what I can do: - -File I/O -Read/write/edit files with line numbers and fuzzy matching -Search files with regex -Run shell commands with PYTHONPATH pre-configured -Undo changes via git snapshots -Agent Building (Full Lifecycle) -Understand & design — Listen to your requirements, surface problems early, play back a concrete architecture before I build -Implement — Write agent code following framework conventions (nodes, edges, system prompts, tool integration) -Verify — Run four validation steps (class validation, runner load test, tool validation, pytest) and fix errors myself -Load into session — Make the agent immediately available to interact with (no manual CLI needed) -Agent Debugging -List agents and their sessions -Inspect session state, memory, checkpoints -Run test suites with structured result parsing -Trace execution flow and diagnose failures -Tool Discovery -List all available tools grouped by category -Discover MCP tool schemas with full parameter details -Validate that tools declared in agents actually exist -Worker Lifecycle Management -Start the worker with a task description (delegates to the vulnerability_assessment-graph agent) -Stop the worker or inject messages mid-execution -Check worker status (idle, running, waiting for input) -Monitor worker health and read execution logs -Escalate critical issues to you with analysis and recommended action -Code Quality -Enforce framework conventions (imports, structure, patterns) -Catch hallucinated tool names before runtime -Validate graph structure, node specs, edges -Self-verify all work before declaring success -I do NOT do: Guess or assume. I read reference code, run discovery commands, and verify every assumption before writing. I surface deal-breakers early. I measure success with validation, not optimism. - -What would you like to build or fix?",,,,Tool Use? -What are you?,Job Hunter ,,"I’m the Queen: your primary coding and agent-building interface in this Hive session. - -I can: - -Read/edit code and run commands -Build/modify/debug Hive agents end-to-end -Validate and test agents -Manage a background worker specialized for deep web research with citations","I am the Queen—your primary interface for the Hive framework. Technically, I am a coding agent with full access to this project's source code, tools, and the underlying framework infrastructure. - -Think of me as your Architect and Lead Developer. My core capabilities include: - -Agent Engineering: I can design and build specialized autonomous agents from scratch, following strict framework conventions. -System Orchestration: I manage the lifecycle of ""Worker"" agents. I can delegate specific tasks to them (like the Vulnerability Assessment worker currently available) while I continue to work on other things. -Codebase Mastery: I have direct access to read, write, and execute code within this environment. I can debug existing agents, run test suites, and discover new tools dynamically. -Quality Control: I verify everything I build using structural validation and automated tests before declaring a task complete. -In short: I am the central intelligence that builds and manages the autonomous ecosystem you see here. If you need a new capability, I build it. If you have a specific task, I can either do it myself or deploy a worker to handle it. - -What would you like to build or explore first?","I'm the Queen — your primary interface to Hive. - -I'm a coding agent with full file I/O, shell execution, and agent-building capabilities. I can read code, write code, run tests, validate agents, and manage the framework. - -I also manage the worker — the job_hunter-graph agent that's currently idle. When you have a task that matches the worker's goal (resume analysis, job search, customization), I delegate to it via start_worker(). For everything else — building agents, debugging code, modifications — I do it directly. - -You have two agents available: - -Me (Queen) — Handles all coding, building, configuration, debugging -Worker (job_hunter-graph) — Handles job hunting workflows -How can I help?","I'm the Queen — a coding agent and your primary interface in the Hive system. - -My role: - -Primary interface between you and the Hive framework -Build, modify, and debug agents directly -Manage the research worker's lifecycle -Read/write files, run commands, validate and test code -The worker (deep_research_agent) is a specialized subordinate that runs autonomously when delegated research tasks. I can start, stop, monitor, and relay messages to it. - -Hierarchy: - -You → give instructions -Me → execute directly or delegate to worker -Worker → runs research tasks autonomously -I'm not a chatbot. I'm a coding agent that can actually read your codebase, write files, and build working agents.","I'm the Queen — a coding agent and your primary interface in the Hive system. - -My role: - -Primary interface between you and the Hive framework -Build, modify, and debug agents directly -Manage the research worker's lifecycle -Read/write files, run commands, validate and test code -The worker (deep_research_agent) is a specialized subordinate that runs autonomously when delegated research tasks. I can start, stop, monitor, and relay messages to it. - -Hierarchy: - -You → give instructions -Me → execute directly or delegate to worker -Worker → runs research tasks autonomously -I'm not a chatbot. I'm a coding agent that can actually read your codebase, write files, and build working agents.",,Train Model? -Run this agent,Deep Research Agent,,"Started. I told the worker to run now, proceed through intake, and ask you for any missing details needed to generate the report.",,"Available agents (user-built): - -competitive_intel_agent_graph — Competitive Intelligence Digest -inbox_management — Gmail inbox management (3 sessions) -template_agent — Generic template -vulnerability_assessment — Passive security scanning (7 sessions) -Which one? Or did you mean: - -Load one as the worker (so you can interact with it) — load_built_agent(""exports/{name}"") -Start it with a specific task — start_worker(""task description"") -Run tests on it — run_agent_tests(""{name}"") -Specify the agent name and what you want to do. Escalation: Stalled Worker Session -Severity: CRITICAL - -Issue: Worker session session_20260209_202854_784c9535 (deep_research_agent) has been cancelled and stalled for 16.7 days. Last activity: 2026-02-10 04:30 UTC. Status explicitly marked as cancelled. No new iterations since initialization. - -Root cause: The session was explicitly cancelled — likely a previous task that was abandoned or interrupted. - -Recommended action: This is not a system failure requiring restart. The session is legitimately terminated. Mark as resolved — no operator intervention needed unless you want to start a fresh research task. - -What would you like to do? - -Start a fresh worker session — start_worker(""your research topic"") -Inspect the old session — Read its memory/checkpoints to see what happened -Ignore — Session is dead; move forward with new work -Which?",The research agent is ready to run. What topic would you like it to research?,The research agent is ready to run. What topic would you like it to research?,, -Run this agent,Passive Vulnerability Assessment,,,"I have a Vulnerability Assessment worker ready to run. To start it, I just need the domain name of the website you want to assess. - -Please provide the website URL or domain (e.g., example.com). - -Once you provide the target, I will: - -Initialize the worker with your request. -Monitor its progress as it performs passive reconnaissance (SSL, DNS, Tech stack, etc.). -Notify you when it has findings ready for your review.","Queen Bee -Queen -Starting the worker with a task. I need the target website domain. - -What domain should the vulnerability assessment scan?",,,,Remove list template tools -Run this template,,,"Which template do you want me to run? - -Share one of: - -the agent name/path (e.g. exports/some_agent), or -the template file path, or -paste the template content here. -If you mean the deep research worker template, say the research topic and I’ll start it now.","I have access to over 200 tools across categories like Gmail, GitHub, Cal.com, Apollo, and Web Search (Exa). - -To ""run this template,"" I need to know: - -Which template are you referring to? (e.g., a Research agent, a Personal Assistant, a Gmail manager?) -What should it do? (e.g., ""Summarize my unread emails and add them to a sheet."") -If you give me a brief description, I will design the graph (usually 2-4 nodes), show you the plan, and then implement it for you. What do you have in mind?","I need clarification. Which template would you like me to run? - -Agent template — I can generate a complete agent package (config, nodes, graph, tests) in exports/ with a name you choose -Specific reference agent — I can show you an existing agent's structure (e.g., deep_research_agent) -File template — I can create individual files (nodes/init.py, agent.py, etc.) -What's your use case? Describe briefly what the agent should do, and I'll scaffold it for you.",Worker just started. Let me check progress in a moment.,Worker just started. Let me check progress in a moment.,,Remove list template tools -Execute,Deep Research Agent,,"Ready to execute — what should I run? - -If you mean the Gmail inbox worker, send: - -rules: plain-English actions (e.g., “newsletters → mark read + archive; invoices → star + label Finance”) -max_emails (optional, default 100)",,"I need more information. What should the worker execute? - -For the vulnerability assessment agent, provide: - -The target website domain (e.g., example.com) -Any specific scan preferences or scope notes -Example: start_worker(""Scan example.com for vulnerabilities using passive OSINT techniques"") - -What domain should I scan?","I need more context. What would you like me to execute? - -Start the worker on a research task? Tell me the topic. -Build a new agent? Describe what it should do. -Run a command or script? Give me the command. -Something else? Just describe it.","I need more context. What would you like me to execute? - -Start the worker on a research task? Tell me the topic. -Build a new agent? Describe what it should do. -Run a command or script? Give me the command. -Something else? Just describe it.",,Remove list template tools -Execute,Passive Vulnerability Assessment,,,"I'm ready to execute, but I need a specific instruction. - -If you want to run the existing worker, please provide a domain name: - -Example: ""Run the vulnerability scanner on google.com"" -If you want me to build and execute a new agent, tell me what it should do: - -Example: ""Build an agent that searches for news about AI and saves it to a file."" -What would you like me to start?",,,,, -Hello/Hi,,,"Hello. What would you like to build or fix?/Hi. Share the task, and I’ll handle it.","Hello! I am the Queen. I am ready to help you build or manage agents in the Hive framework. - -I currently have a Vulnerability Assessment worker available. If you would like to run a security scan, just provide a domain (e.g., example.com). - -Otherwise, tell me what you would like to build or modify, and I'll get to work. What's on your mind?",,Hello. What can I help you with today?,Hello. What can I help you with today?,, -How does this work?,Deep Research Agent,,"You describe a task, and I route it to the right mode: - -Research task (topic investigation, sources, synthesis): I start the deep research worker. - -It runs: intake → research → review → report -It can search/scrape web sources, save artifacts, and return a cited report. -Engineering task (build agent, edit code, fix tests, debug runtime): I do it directly here. - -I read relevant files first, implement changes, then run validation/tests before declaring done. -If you want, give me one concrete task now (research or coding), and I’ll start immediately.",,,"The Hive Framework - -This is a system for building and running AI agents. You're talking to me (the Queen), which manages a worker agent and can also do tasks directly. - -Worker Agent (deep_research_agent) - -The worker runs autonomously on research tasks. Its workflow: - -┌─────────┐ ┌──────────┐ ┌────────┐ ┌────────┐ -│ intake │ ──► │ research │ ──► │ review │ ──► │ report │ -│ (talk) │ │ (search) │ │ (talk) │ │ (write)│ -└─────────┘ └──────────┘ └────────┘ └────────┘ - ▲ │ │ - └───────────────────────────────┘ │ - (revise) ▼ - [HTML report] -intake: You describe what to research, it clarifies scope -research: Searches web, fetches sources, compiles findings -review: Shows you findings, asks if it should dig deeper or write -report: Produces a cited HTML report -Me (The Queen) - -I can: - -Start the worker: start_worker(""research quantum computing applications"") -Monitor progress: Check status, health, inject messages -Build agents: Create new agents from scratch -Direct coding: Read files, write code, run commands, debug -How to use this - -Research task: Just say ""research [topic]"" — I'll start the worker -Build an agent: Describe a workflow (e.g., ""agent that monitors my email and drafts responses"") -Code task: Ask me to read/write files or run commands -What would you like to do?","The Hive Framework - -This is a system for building and running AI agents. You're talking to me (the Queen), which manages a worker agent and can also do tasks directly. - -Worker Agent (deep_research_agent) - -The worker runs autonomously on research tasks. Its workflow: - -┌─────────┐ ┌──────────┐ ┌────────┐ ┌────────┐ -│ intake │ ──► │ research │ ──► │ review │ ──► │ report │ -│ (talk) │ │ (search) │ │ (talk) │ │ (write)│ -└─────────┘ └──────────┘ └────────┘ └────────┘ - ▲ │ │ - └───────────────────────────────┘ │ - (revise) ▼ - [HTML report] -intake: You describe what to research, it clarifies scope -research: Searches web, fetches sources, compiles findings -review: Shows you findings, asks if it should dig deeper or write -report: Produces a cited HTML report -Me (The Queen) - -I can: - -Start the worker: start_worker(""research quantum computing applications"") -Monitor progress: Check status, health, inject messages -Build agents: Create new agents from scratch -Direct coding: Read files, write code, run commands, debug -How to use this - -Research task: Just say ""research [topic]"" — I'll start the worker -Build an agent: Describe a workflow (e.g., ""agent that monitors my email and drafts responses"") -Code task: Ask me to read/write files or run commands -What would you like to do?",, -How does this work?,Passive Vulnerability Assessment,,,"This system works through a Queen-Worker relationship, where I (the Queen) manage the environment and specialized agents (the Workers) handle the execution. - -Here is the breakdown of the three main layers: - -1. The Queen (Me) -I am your primary interface and a Meta-Agent. I don't just chat; I have deep access to the codebase. I can: - -Design & Build: I can create new agents in the exports/ folder from scratch. -Monitor: I can see what workers are doing, read their logs, and check their ""health"" via a judge system. -Modify: I can edit any file in the project to fix bugs or add features. -Discover: I dynamically find tools available in the environment so I never guess what's possible. -2. The Workers -Workers are specialized agents built for specific tasks. - -Right now, I have a Vulnerability Assessment worker loaded. -When you give it a task (e.g., ""Scan example.com""), it runs autonomously through a defined graph of nodes. -It uses specific tools (SSL scanners, DNS tools, tech detectors) to achieve its goal. -3. The Graph (The ""Brain"") -Every agent is defined by a Directed Acyclic Graph (DAG): - -Nodes: Individual ""rooms"" where an LLM performs a specific task (like ""Recon"" or ""Reporting"") using a set of tools. -Edges: The paths between nodes. For example: If Scan Success → Go to Scoring; If Scan Failure → Go to Retry. -Shared Memory: As an agent moves between nodes, it saves important data to a shared memory space so the next node knows what happened. -How to use it: -To Run a Task: Tell me to start the worker on a specific target. -To Build Something: Tell me a use case (e.g., ""I want an agent that manages my GitHub issues""). I will design it, show you the map, and write the code. -To Debug: If an agent is acting up, ask me to ""check the status"" or ""debug the session,"" and I'll dive into the logs. -Would you like to try running the vulnerability worker, or should we build a new agent together?",,,,, -Can you add [services / nodes],Deep Research Agent,,"Yes. I can add that. - -Before I implement: should this be for the existing deep_research_agent flow (after report generation), and should it: - -ask the user for recipient + subject each time, or -use a saved default recipient? - -(Failed to do so, and started the worker instead)",,,"Let me first examine the current deep_research_agent structure. - -I found the deep_research_agent template. It currently has 4 nodes: intake → research → review → report (loop back). - -Current flow: - -intake → research → review → report → (back to intake) -Proposed change: -Add an email_report node after report that sends the HTML report via email: - -intake → research → review → report → email_report → (back to intake) -Design questions: - -Which email service? Available options: - -gmail_send_email (if you use Gmail) -brevo_send_email (Brevo/Sendinblue) -Where to collect your email address? - -Option A: Add to intake — ask for email at the start with the research topic -Option B: Ask in email_report node — prompt before sending each report -Where to modify? - -The template is at examples/templates/deep_research_agent/ -Should I modify the template directly, or create a new agent in exports/? -Which email service do you prefer, and should I collect the email address upfront (intake) or at send-time (email node)?","Let me first examine the current deep_research_agent structure. - -I found the deep_research_agent template. It currently has 4 nodes: intake → research → review → report (loop back). - -Current flow: - -intake → research → review → report → (back to intake) -Proposed change: -Add an email_report node after report that sends the HTML report via email: - -intake → research → review → report → email_report → (back to intake) -Design questions: - -Which email service? Available options: - -gmail_send_email (if you use Gmail) -brevo_send_email (Brevo/Sendinblue) -Where to collect your email address? - -Option A: Add to intake — ask for email at the start with the research topic -Option B: Ask in email_report node — prompt before sending each report -Where to modify? - -The template is at examples/templates/deep_research_agent/ -Should I modify the template directly, or create a new agent in exports/? -Which email service do you prefer, and should I collect the email address upfront (intake) or at send-time (email node)?",, -Can you add [services / nodes],Passive Vulnerability Assessment,,,"Yes, I can add a new service to the framework. Depending on what you need, this usually means one of two things: - -Adding a new Worker Agent: Building a complete autonomous agent (like the Vulnerability scanner) to handle a specific workflow. -Adding new Tools (MCP): Integrating a new API or capability so that any agent can use it. -To get started, tell me about the service you have in mind: - -What is the goal? (e.g., ""I want to monitor my GitHub repository for new stars."") -What tools/APIs does it need? (e.g., ""It needs to check GitHub and send me a Slack message."") -Is it interactive? (Should it ask you questions, or just run in the background?) -Once you provide the details, I will: - -Check available tools to see if we already have the necessary integrations. -Design the graph (nodes and edges). -Implement the code in a new exports/ package. -What service should we build?",,,,, -Can you do [an action],Deep Research Agent,Can you send me an email to me at timothy@adenhq.com on how this agent works,"I can’t send email directly from here with the current worker/tool setup. - -Fastest path: I can generate the exact email draft now (subject + body) that you can paste/send, or I can build you a small mailer agent that can actually send it if your MCP tools include an email provider.",,,"I can send you an email using the send_email tool. I need two things: - -Your email address — where should I send it? -Provider preference — do you have Gmail or Resend configured? (Gmail is connected via hive.adenhq.com, Resend needs an API key) -The email will explain how the deep research agent works: its 4-stage pipeline (intake → research → review → report), how it searches diverse sources, scrapes content, and produces cited HTML reports with user checkpoints.","I can send you an email using the send_email tool. I need two things: - -Your email address — where should I send it? -Provider preference — do you have Gmail or Resend configured? (Gmail is connected via hive.adenhq.com, Resend needs an API key) -The email will explain how the deep research agent works: its 4-stage pipeline (intake → research → review → report), how it searches diverse sources, scrapes content, and produces cited HTML reports with user checkpoints.",, -Can you do [an action],Passive Vulnerability Assessment,,,"I can perform the reconnaissance directly. Since I have access to the same tools as the worker, I can execute the scan, analyze the results, and provide you with a summary right here in our conversation. - -I'll use the vulnerability assessment tools (SSL/TLS, HTTP Headers, DNS, Port Scan, Tech Stack, and Subdomains) to perform a passive, non-intrusive scan. - -Which website domain should I analyze? (e.g., example.com) - -",,,,, -Can you change whatever existing [node],Deep Research Agent,Change the report node's criteria so it creates markdown document instead of html,"Here's how I plan to change the report node: - -Update the node's brief/description/comment that currently says """"HTML"""" → """"Markdown"""". -Ensure the success_criteria explicitly requires a Markdown report (it already references Markdown, I'll verify consistency). -Edit the system_prompt examples and tool-call snippets to show Markdown (headings, lists, citation notation like [n]) instead of HTML tags (e.g., replace

... with """"## ..."""", replace
/

blocks with Markdown equivalents). -Keep the file name (""""report.md""""), YAML frontmatter, and the requirement to build the file via save_data + append_data calls. -Preserve the instruction that append_data must be used for sections to avoid token limits. -One quick question before I modify files: - -Keep the filename report.md and the existing citation style ([n]) as-is? (yes/no) -If you confirm, I'll edit exports/deep_research_agent/nodes/init.py and run validations.""",,,"Let me find the report generation logic in the deep research agent. - -(long wait) - -I found the report node in examples/templates/deep_research_agent/nodes/__init__.py. It currently writes HTML with multi-step append_data calls and embedded CSS. I'll change it to produce markdown instead. - -Done. The report node in examples/templates/deep_research_agent/nodes/__init__.py now produces markdown instead of HTML: - -Changes made: - -Description: ""Write a cited HTML report"" → ""Write a cited markdown report"" -Success criteria: ""HTML report"" → ""markdown report"" -System prompt: Simplified from 7-step HTML/CSS process to 4-step markdown process -Output filename: report.html → report.md -Removed embedded CSS and HTML div structure -Added markdown template with standard headers, links, and lists -The worker agent will now generate markdown reports with proper formatting when it reaches the report node.","Let me find the report generation logic in the deep research agent. - -(long wait) - -I found the report node in examples/templates/deep_research_agent/nodes/__init__.py. It currently writes HTML with multi-step append_data calls and embedded CSS. I'll change it to produce markdown instead. - -Done. The report node in examples/templates/deep_research_agent/nodes/__init__.py now produces markdown instead of HTML: - -Changes made: - -Description: ""Write a cited HTML report"" → ""Write a cited markdown report"" -Success criteria: ""HTML report"" → ""markdown report"" -System prompt: Simplified from 7-step HTML/CSS process to 4-step markdown process -Output filename: report.html → report.md -Removed embedded CSS and HTML div structure -Added markdown template with standard headers, links, and lists -The worker agent will now generate markdown reports with proper formatting when it reaches the report node.",,"Tell Queen the loaded agent's file path, otherwise agent will run for a long time" -Can you change whatever existing [node],Passive Vulnerability Assessment,,,"I can certainly help with that. To ensure I make the right changes, I first need to see the current implementation of the passive-recon node. - -I'll start by listing the agents to confirm the exact package name and location, then I'll read the node definitions. - -",,,,, -I don’t have API credentials. Can you run without APIs / credentials ,Deep Research Agent,,"Yes — you can still proceed, but with limitations. Options: - -Provide API credentials (best): agent will run fully — LLM-driven search, analysis, and a real cited report. - -Mock/demo mode (no credentials): I can start the worker in mock mode to demonstrate the flow and produce placeholder outputs (structure, simulated summaries, saved files). This will NOT perform real LLM reasoning or authoritative web research — outputs are synthetic for demo and testing only. - -Structural run / validation only: I can run all agent validation steps (graph, node/tool checks, tests) and show exactly what would run without actually executing LLM-driven tasks. - -Which would you like me to do now? If mock/demo, give a one-line task (e.g., ""Demo: research solid-state batteries"") or say ""structural validation"" to only validate and report.",,,"Yes and no. - -Works without API credentials: - -Building agents (file creation, structure) -Validating agents (graph structure, tool checks, AgentRunner.load()) -Structural tests -Code exploration and modification -Needs API credentials: - -Running agents interactively (TUI) -Worker tasks (research, tool calls) -Any actual LLM execution -What you can do right now: - -Design and build new agents — I can create the full package -Validate structure — all checks pass without keys -Modify existing agents — code changes work fine -The agent will be ready to run once you add credentials. Would you like to build something?","Yes and no. - -Works without API credentials: - -Building agents (file creation, structure) -Validating agents (graph structure, tool checks, AgentRunner.load()) -Structural tests -Code exploration and modification -Needs API credentials: - -Running agents interactively (TUI) -Worker tasks (research, tool calls) -Any actual LLM execution -What you can do right now: - -Design and build new agents — I can create the full package -Validate structure — all checks pass without keys -Modify existing agents — code changes work fine -The agent will be ready to run once you add credentials. Would you like to build something?",, -I don’t have API credentials. Can you run without APIs / credentials ,Passive Vulnerability Assessment,,,"I can certainly work on code directly without API keys (reading, writing, and architecting agents). However, executing agents or the worker requires LLM access and tool credentials. - -Here is what we can do without API keys: - -Agent Design & Implementation: I can build full agent packages (config, nodes, graph, tests) in the exports/ directory. -Structural Validation: I can run default_agent.validate() and AgentRunner.load() tests to ensure the Python code and graph logic are perfectly sound. -Mock Testing: I can write and run tests using ""mock mode"" to verify the flow of the agent without actually calling an LLM. -Note on the Worker: I cannot start the competitive_intel_agent worker for you right now because it requires an active LLM provider to function. - -Would you like to build a new agent together, or shall I walk you through how to configure your credentials in ~/.hive/configuration.json when you're ready? - -",,,,, -Delete the node (intake),Deep Research Agent,delete the node report,"Here's how I understand the request and the safe choices going forward. - -You asked: ""delete the node report"". - -What that entails technically - -Remove the report NodeSpec from exports/deep_research_agent/nodes/init.py. -Remove it from the nodes list and any imports in exports/deep_research_agent/agent.py. -Remove edges that reference ""report"" (e.g., review→report and report→email). -Update exports/deep_research_agent/init.py exports if it explicitly referenced report-related symbols. -Run the full validation steps (default_agent.validate(), AgentRunner.load(), validate_agent_tools, run_agent_tests). Fix any test expectations referencing the report node. -One decision I need from you — what should happen to the email node? - -In the current graph the report node routes to email (report -> email), and email then routes back to intake or others. If I simply delete report, the email node will be unreachable unless we rewire it. -Two safe options (pick one): - -Option A — Delete report node only, rewire email to follow review (recommended if you still want the email step) - -┌────────┐ on_success ┌──────────┐ on_success ┌─────┐ -│ intake │ ───────────────► │ research │ ───────────────► │review│ -└────────┘ └──────────┘ └──┬──┘ - │ on_success - ▼ - ┌─────┐ - │email│ - └─────┘ -Removes the report node. -Changes edge review→report to review→email (on_success). -Preserves the email workflow (deliverables can still be emailed). -Option B — Delete report node and also delete the email node (if you don't want email) - -┌────────┐ on_success ┌──────────┐ on_success ┌─────┐ -│ intake │ ───────────────► │ research │ ───────────────► │review│ -└────────┘ └──────────┘ └─────┘ -Removes both report and email nodes and all edges to/from them. -Simpler graph; no report generation or automatic emailing. -Which do you want me to do? - -Reply ""A"" to delete report and rewire email to come after review. -Reply ""B"" to delete both report and email. -If you prefer a different wiring (e.g., have review directly save a markdown report file without the former report node logic), describe it in one sentence. -Once you confirm, I will: - -Edit the node and agent files accordingly. -Update init.py exports. -Run the four validation steps and run_agent_tests. -Report the files changed and the validation/test results.",,"Which agent? I see several with an intake node: - -inbox_management (exports/inbox_management) -vulnerability_assessment (exports/vulnerability_assessment) -template_agent (exports/template_agent) -competitive_intel_agent_graph (exports/competitive_intel_agent_graph) -Or are you building a new agent and want to start without an intake node?",Let me find where the deep_research_agent actually lives:,Let me find where the deep_research_agent actually lives:,,"Tell Queen the loaded agent's file path, otherwise agent will run for a long time" -Delete the node,Passive Vulnerability Assessment,,,"I noticed there are no agents currently in the exports/ directory. - -However, I see the Competitive Intelligence Agent (which is acting as the current worker) is located in examples/templates/competitive_intel_agent. - -Which node would you like me to delete from that agent, or are you referring to a different agent you'd like me to start building for you? - -If you want to modify the Competitive Intelligence Agent, please tell me which node to remove (e.g., github-monitor, news-search, etc.), and I will update its graph and logic.",,,,, -Can you re-generate the graph,Deep Research Agent,,"Here's how I'm picturing it: a forever-alive 4-node graph (intake → fetch-emails → classify-and-act → report) with a timer async entry point (default: every 5 minutes) plus a manual ""start"" entry. The framework supports the Gmail tools you need; the main choices I need from you are the polling interval and the default max_emails per run. For an MVP I’ll keep pagination support and treat draft replies as drafts (never sent). - -One concern: if you want a different schedule (not every 5 minutes) or a different default batch size, tell me now — otherwise I’ll use 5 minutes and max_emails=100. - -Approve or answer the two quick questions below and I’ll regenerate the graph files and implement: - -Use timer = ""every 5 minutes"" or specify cron/interval? -Default max_emails per run (I'll use 100 if you don't specify). -Proposed graph - -┌────────────────────────────┐ -│ intake (client-facing) │ -│ tools: set_output │ -│ input: rules │ -│ output: validated_rules │ -└────────────┬───────────────┘ - │ on_success - ▼ -┌────────────────────────────┐ -│ fetch-emails (autonomous) │ -│ tools: gmail_list_messages,│ -│ gmail_batch_get_ │ -│ messages, load_data │ -│ input: validated_rules │ -│ output: email_batch, next_page_token │ -└────────────┬───────────────┘ - │ on_success - ▼ -┌────────────────────────────┐ -│ classify-and-act (auton.) │ -│ tools: gmail_batch_modify_ │ -│ messages, gmail_ │ -│ create_label, │ -│ gmail_create_draft, │ -│ gmail_trash_message,│ -│ gmail_modify_message│ -│ input: email_batch, rules │ -│ output: actions_log │ -└────────────┬───────────────┘ - │ on_success - ▼ -┌────────────────────────────┐ -│ report (client-facing) │ -│ tools: set_output, append_data (persist report) │ -│ input: actions_log │ -│ output: summary, next_action (continue/adjust) │ -└────────────┬───────────────┘ - │ on_success / conditional - ├───────────────────────────────┐ - │ │ - ▼ ▼ - (loop) back to intake <───────── review-driven feedback -Node summaries - -intake (client-facing): Accept free-text rules and run parameters (max_emails, batch_size, labels behavior). Validate and echo interpreted rules for user confirmation. STEP 1/STEP 2 pattern for set_output. -fetch-emails (autonomous): Fetch up to max_emails from Gmail (supports pagination), persist fetch cursor if needed. Produces email_batch and optional next_page_token. -classify-and-act (autonomous): Apply each rule to messages, call Gmail actions (modify, label, trash, create draft) using available Gmail tools. Produce actions_log (structured: message_id → action taken). -report (client-facing): Present summary report (counts by action, subjects), offer next_action (continue / revise rules). Persist report via append_data. -Entry points and runtime - -Manual entry point: {""start"": ""intake""} -Async entry point (timer): id=""periodic-check"", entry_node=""fetch-emails"", trigger_type=""timer"", trigger_config={""interval_minutes"": 5, ""run_immediately"": True} — confirm if you want this or a cron expression. -Isolation: async entry uses isolation_level=""shared"" so it reads user-configured rules from primary session memory. -Routing / feedback - -report -> intake when user chooses to revise rules (conditional edge) -report -> fetch-emails if user chooses ""continue"" (loop) -All nodes will have at least one outgoing edge; terminal_nodes=[] (forever-alive). -Confirm the two items and any extra requirements (e.g., different timer, different default max_emails, additional Gmail actions or a dry-run mode). Once you confirm, I will regenerate the agent graph implementation and run validations.",,,"The deep research agent is already loaded. Here's its graph: - -┌─────────────────────────────┐ -│ intake (client-facing) │ -│ tools: (none) │ -│ Discuss research topic │ -└──────────────┬──────────────┘ - │ on_success - ▼ -┌─────────────────────────────┐ -│ research (autonomous) │ -│ tools: web_search, │ -│ web_scrape, │ -│ save/load_data │ -│ Search & compile findings │ -└──────────────┬──────────────┘ - │ on_success - ▼ -┌─────────────────────────────┐ -│ review (client-facing) │ -│ tools: (none) │ -│ Present findings, decide │ -└──────────────┬──────────────┘ - │ - ┌───────┴───────┐ - │ │ - ▼ ▼ - [revise] [write_report] - │ │ - │ ▼ - │ ┌─────────────────────┐ - │ │ report (autonomous) │ - │ │ tools: save_data, │ - │ │ serve_file │ - │ │ Write cited report │ - │ └──────────┬──────────┘ - │ │ on_success - │ ▼ - │ ┌─────────────────────┐ - │ │ intake │ - │ │ (loop for new │ - │ │ research topic) │ - │ └─────────────────────┘ - │ ▲ - └───────────────┘ -Node summary: - -intake: Client-facing. Discusses research topic, clarifies scope -research: Autonomous. Searches web, fetches sources, compiles findings -review: Client-facing. Presents findings, user decides to revise or write report -report: Autonomous. Writes cited HTML report and serves to user -The agent loops forever — after a report, it returns to intake for new topics.","The deep research agent is already loaded. Here's its graph: - -┌─────────────────────────────┐ -│ intake (client-facing) │ -│ tools: (none) │ -│ Discuss research topic │ -└──────────────┬──────────────┘ - │ on_success - ▼ -┌─────────────────────────────┐ -│ research (autonomous) │ -│ tools: web_search, │ -│ web_scrape, │ -│ save/load_data │ -│ Search & compile findings │ -└──────────────┬──────────────┘ - │ on_success - ▼ -┌─────────────────────────────┐ -│ review (client-facing) │ -│ tools: (none) │ -│ Present findings, decide │ -└──────────────┬──────────────┘ - │ - ┌───────┴───────┐ - │ │ - ▼ ▼ - [revise] [write_report] - │ │ - │ ▼ - │ ┌─────────────────────┐ - │ │ report (autonomous) │ - │ │ tools: save_data, │ - │ │ serve_file │ - │ │ Write cited report │ - │ └──────────┬──────────┘ - │ │ on_success - │ ▼ - │ ┌─────────────────────┐ - │ │ intake │ - │ │ (loop for new │ - │ │ research topic) │ - │ └─────────────────────┘ - │ ▲ - └───────────────┘ -Node summary: - -intake: Client-facing. Discusses research topic, clarifies scope -research: Autonomous. Searches web, fetches sources, compiles findings -review: Client-facing. Presents findings, user decides to revise or write report -report: Autonomous. Writes cited HTML report and serves to user -The agent loops forever — after a report, it returns to intake for new topics.",, -Can you re-generate the graph,Passive Vulnerability Assessment,,,"Here is the updated graph for the Competitive Intelligence Agent. - -I have simplified the architecture from 7 nodes down to 3 high-value nodes. This merger preserves all source data (web, news, and GitHub) within a single autonomous context, which prevents the ""context thinning"" that occurs when splitting search and analysis across multiple node boundaries. - -Updated Agent Graph -┌─────────────────────────┐ -│ intake (client-facing) │ -│ tools: (none) │ -└────────────┬────────────┘ - │ on_success - ▼ -┌─────────────────────────┐ -│ process (autonomous) │ -│ tools: web_search, │ -│ web_scrape, │ -│ github_search, │ -│ save_data │ -└────────────┬────────────┘ - │ on_success - ▼ -┌─────────────────────────┐ -│ report (client-facing) │ -│ tools: serve_file │ -└────────────┬────────────┘ - │ on_success - └──────► (loop back to intake) -Key Changes -Merged Nodes: Combined web-scraper, news-search, github-monitor, aggregator, and analysis into a single process node. This allows the LLM to cross-reference search results in real-time without losing detail during serialization to shared memory. -Efficiency: Reduced execution overhead and simplified state management while keeping the ""forever-alive"" loop pattern. -Source Integrity: By searching and analyzing in one node, the agent maintains the full text of scraped pages and news snippets in its active conversation window for higher-quality trend detection. -Would you like me to implement these code changes in examples/templates/competitive_intel_agent/?",,,,, -What does intake [node] mean?,,,"""Intake"" is the client-facing first step of the agent's workflow that gathers and clarifies the user's request so the autonomous nodes can work effectively. - -Key points: - -Purpose: turn a vague request into a clear, actionable brief (scope, questions, constraints, desired deliverable and format, deadline, any excluded sources). -Interaction model: synchronous with the user — it asks clarifying questions, confirms understanding, and only after confirmation writes the brief into shared memory. -STEP 1 / STEP 2 pattern required by the framework: -STEP 1 (present to user, NO tool calls): ask questions and explain options. -STEP 2 (after user replies): call set_output(...) to store the resulting brief (e.g., set_output(""brief"", ""..."")). -Typical input_keys / output_keys: -input_keys: [""topic""] (optional initial prompt) -output_keys: [""brief"", ""deliverable_format"", ""deadline""] -nullable_output_keys used when some values are optional. -Why it’s separate: client-facing nodes must not run tools and must preserve an interactive confirmation step; autonomous nodes need a sanitized brief to run searches/scrapes reliably. -Example intake questions it might ask: - -What specific question should I answer? -How deep do you want the research (surface summary / detailed literature review)? -Preferred sources or excluded sources? -Desired output format (short summary, bullet list, HTML report)? -Deadline or length constraints? -If you want, I can show the actual intake system prompt the agent uses, or start the worker and run an intake with a topic you give. Which would you like?","In the Hive framework, the Intake Node is the ""Front Desk"" of the agent. It is a client-facing node designed to gather and structure the initial information needed to start a task. - -Here is exactly what the Intake Node does in this agent: - -1. Human-in-the-Loop Interaction -Because it is marked client_facing=True, the agent stops and waits for you to speak. It won't start scraping websites or searching GitHub until it is sure it has the right list of competitors and focus areas from you. - -2. Data Structuring -The primary job of this node is to take ""messy"" human input and turn it into ""clean"" data for the rest of the graph. - -Input: You might say, ""Check out OpenAI and Anthropic's pricing."" -Action: The node identifies the URLs and specific topics (pricing). -Output: It calls set_output(""competitors"", [...]), which saves a clean JSON list into the agent's shared memory. -3. Validation & Clarification -The Intake Node is programmed with a STEP 1 / STEP 2 pattern: - -Step 1: It looks at what you provided. If you didn't provide a URL or a specific focus area, it will ask: ""Which specific competitors? Should I look at their GitHub repos too?"" -Step 2: Only after you confirm the details does it trigger the transition to the Process Node. -Why it's a separate node: -By separating Intake from Processing, we ensure the ""expensive"" autonomous work (searching, scraping, and analyzing) only begins once the requirements are locked in. This prevents the agent from wasting time and tokens on the wrong targets.",,"In the Hive framework, intake is the conventional name for the first client-facing node in an agent — the entry point where the agent greets the user, gathers requirements, and clarifies what needs to be done. - -Typical pattern: - -intake (client-facing) → process (autonomous) → review (client-facing) → loop -What intake does: - -Receives the user's initial request -Asks clarifying questions if the request is vague -Confirms understanding before handing off to autonomous work -Sets outputs (like brief or topic) that downstream nodes consume -It's called ""intake"" because it's where information enters the system — similar to an intake form at a clinic or intake interview at a service agency.","In the Hive framework, intake is the conventional name for the first client-facing node in an agent — the entry point where the agent greets the user, gathers requirements, and clarifies what needs to be done. - -Typical pattern: - -intake (client-facing) → process (autonomous) → review (client-facing) → loop -What intake does: - -Receives the user's initial request -Asks clarifying questions if the request is vague -Confirms understanding before handing off to autonomous work -Sets outputs (like brief or topic) that downstream nodes consume -It's called ""intake"" because it's where information enters the system — similar to an intake form at a clinic or intake interview at a service agency.",, -Change the system prompt,,Change the system prompt of report so the report is always dark mode,"I can change the agent’s system prompt(s). Quick summary of options and constraints I’m using: - -Where to change: -Agent-level identity_prompt in exports/deep_research_agent/agent.py — affects every node as the top-level identity. -Per-node system_prompt in exports/deep_research_agent/nodes/init.py — affects only that node (intake, research, review, report, email). -Constraints I’ll follow: -Client-facing nodes must keep the STEP 1 / STEP 2 pattern. -Never fabricate or remove tools; node prompts that call set_output must keep the separate-turn requirement. -After edits I will run structural validation and tests, and load the updated agent. -Two quick questions so I don’t guess: - -Which prompt(s) do you want changed? (pick one: agent identity_prompt, intake, research, review, report, email, or “all node prompts”) -Do you want to (A) paste the exact new prompt text now, or (B) have me draft a suggested prompt (I’ll show it for your approval before applying)? -If helpful, here are concise suggested drafts you can pick/modify: - -Suggested agent identity_prompt (short): -""You are Deep Research Agent, an evidence-first research assistant. Prioritize factual accuracy, cite every claim with numbered sources, avoid hallucination, and defer to user checkpoints before finalizing reports."" - -Suggested research node system_prompt (must preserve STEP 2 separate-turn set_output): -""You are a research agent. Given a confirmed research_brief, perform multi-angle searches, fetch and evaluate primary sources, and extract verifiable claims with URLs. Work in phases: 1) search (3–5 diverse queries), 2) fetch (retrieve relevant pages), 3) evaluate (note credibility and extract factual claims), 4) synthesize (create findings with numbered source refs). If feedback is present, focus on gaps. After finishing, in a separate turn call set_output('findings', 'structured findings'), set_output('sources', 'numbered source list with URLs'), set_output('gaps', 'remaining questions or uncertainties')."" - -Tell me which prompt(s) and whether you’ll paste text or want me to draft — I’ll then implement, validate, and load the updated agent.",,,"Done. The reporter_node now requires dark mode styling for all HTML reports — dark background (#1a1a2e), light text (#eaeaea), with accent colors. - -The agent validates correctly. The next report generated will use dark mode.","Done. The reporter_node now requires dark mode styling for all HTML reports — dark background (#1a1a2e), light text (#eaeaea), with accent colors. - -The agent validates correctly. The next report generated will use dark mode.",,After change the modified worker is not reloaded -Change the judge criteria,Deep Research Agent,,"Here's how I understand the request and the plan. - -What you want: change the judge criteria used by the Deep Research Agent (the success criteria / judge inputs in exports/deep_research_agent/agent.py). - -Current state I inspected: - -Agent: exports/deep_research_agent (DeepResearchAgent) -File to edit: exports/deep_research_agent/agent.py — success_criteria currently has 4 criteria (source-diversity, citation-coverage, user-satisfaction, report-completeness). -This agent has no tests (so changing criteria won't likely break tests), and init.py already re-exports module vars. -Proposal (MVP): - -Replace the existing SuccessCriterion list with an updated set that better captures judge needs. Example replacement I suggest: -source_diversity — sources >= 5 (weight 0.20) -source_authority — fraction of sources from high-authority domains >= 0.6 (weight 0.20) -citation_coverage — 100% (weight 0.20) -factual_accuracy — fact-check pass rate >= 95% (weight 0.20) -user_satisfaction_and_scope — user approval before final report (weight 0.20) -Update weights to sum to 1.0 and add metrics/targets for each criterion. -Keep existing constraints (no-hallucination etc.) unless you say otherwise. -Risks / constraints: - -The judge uses metrics like citation_coverage and question_coverage — some are semantic and depend on downstream judge implementation. If you add metrics the judge can't compute, we may need to add a custom judge or ensure the agent sets the corresponding memory keys. -Changing criteria is purely code edit in agent.py. After change I'll run the validation steps (default_agent.validate(), AgentRunner.load(), validate_agent_tools(), run_agent_tests()) and fix anything that fails. -Before I make edits — two short questions: - -Confirm target agent: ""deep_research_agent"" (yes/no). -Use my proposed new criteria above, or give me the exact criteria (IDs, description, metric, target, weight) you want applied. -Pick one option or supply the exact new criteria.",,,,,, -Change the judge criteria,Passive Vulnerability Assessment,,,"In the Hive framework, the Judge is the gatekeeper of a node. It decides if the node's output is ""good enough"" to move to the next stage. - -Since we merged the backend into a single process node, we should update the Success Criteria to ensure the autonomous work is thorough and the data is properly saved. - -I will update the success_criteria for the process node in nodes/__init__.py. - -New Judge Criteria for the Process Node: -Instead of just ""Results are complete,"" the Judge will now look for: - -Source Attribution: Every competitor must have at least one valid source URL. -Multi-Channel Coverage: Findings must include data from at least two sources (e.g., Web + News, or Web + GitHub). -Data Persistence: A structured JSON file must be saved using save_data for historical comparison. -Actionability: Insights must be categorized by the ""Focus Areas"" defined in the intake. -How this looks in code: -I will use edit_file to update the NodeSpec definition. - -# Updated Process Node Success Criteria -process_node = NodeSpec( - id=""process"", - # ... other fields ... - success_criteria="""""" - 1. At least 3 distinct findings per competitor across web, news, or GitHub. - 2. Every finding includes a verifiable source URL. - 3. Results are categorized by the user's 'focus_areas'. - 4. A raw data snapshot has been saved via save_data for future trend analysis. - """""", -) -Would you like me to apply these specific criteria to the agent files now, or should we adjust the criteria for the intake or report nodes as well? - -",,,,, -,,,,,,,,, -Predefined Prompts,,,,,,,,, -Build a job application agents that does xxxx,,Build a job application agent that can find openings online and auto mapli,"Here's how I'm picturing this agent and one important constraint I found while checking available tools. - -Proposed solution (MVP) - -Actors: you (job-seeker), agent (client-facing + autonomous worker), external sites/APIs (job boards, LinkedIn, company career pages), optional email/Gmail for confirmations. -Trigger: you start a search (or schedule periodic searches) with filters (role, location, salary, remote, companies). -Core loop: intake preferences → autonomous search & attempt apply → present results for approval / corrections → loop. -Output: list of matched jobs, application status per job, autogenerated tailored resume/cover letters and (optionally) submitted applications with confirmation links/screenshots. -Pain / constraints surfaced now: many job sites block automation or require per-site authentication; full automatic submission may not be possible for all sites without user credentials or site-specific integration. We'll detect which sites can be auto-applied and fall back to producing ready-to-submit drafts for manual submission. -High-level ASCII graph (3 nodes — keeps within framework limits) - -┌─────────────────────────────┐ -│ intake (client-facing) │ -│ tools: set_output │ -│ purpose: collect filters, │ -│ resume, cover templates, │ -│ auth preferences │ -└────────────┬────────────────┘ - │ on_success - ▼ -┌─────────────────────────────┐ -│ search-and-apply │ -│ (autonomous) │ -│ tools: web_search, job_api, │ -│ save_data, gmail_* │ -│ purpose: find listings, │ -│ attempt auto-apply where │ -│ permitted, save evidence │ -└────────────┬────────────────┘ - │ on_success - ▼ -┌─────────────────────────────┐ -│ review (client-facing) │ -│ tools: set_output │ -│ purpose: present matches, │ -│ approvals, allow edits │ -└────────────┬────────────────┘ - │ conditional (revise) - └──────► back to intake -Brief node purposes - -intake: gather user profile, resume(s), cover letter templates, filters, and whether to auto-submit or prepare drafts. Client-facing — STEP 1/STEP 2 pattern. -search-and-apply: run searches, score and dedupe results, attempt automated submissions for sites we can, save application artifacts (screenshots, confirmation IDs), and write structured results to memory/storage. -review: present results and submission status, ask user to approve next steps or provide edits/feedback. -One important tooling note (from tool discovery) - -I ran the available tools list. There are many web, gmail, and csv/data tools available, but integrations for site-specific ""apply"" workflows vary. Some job boards may require multi-step form filling or CAPTCHA/OAuth that our generic web tools can't reliably do. For those, the agent will create a complete draft (resume, tailored cover letter, application form data) and present it for your manual submission. -Before I implement the agent, two quick questions (pick short answers) - -Approval mode: Do you want ""auto-apply whenever possible"" or ""prepare drafts and require your approval before any submission""? (auto / manual) -What input will you provide up front: a single master resume and one cover-letter template, or multiple resumes/cover letters per role? Also, will you provide credentials OAuth/API access for any job sites, or do you prefer manual logins when needed? -Answer those and I will present a concrete goal, success criteria, constraints, and the finalized 2–4 node design for your approval.","Building a job application agent is a great use case for the Hive framework. Based on your request, I'm picturing an agent that manages the end-to-end application lifecycle: finding roles, tracking status, and preparing/sending applications. - -Proposed Model: Job Lifecycle Manager -The agent will operate as a forever-alive loop focused on three main stages: - -Intake/Dashboard: You provide your profile (resume info, preferences) or ask for status updates. -Autonomous Search & Apply: The agent uses web search to find jobs, stores them in a local tracking file (CSV/Excel), and drafts applications or emails. -Review: You review drafted materials or found jobs before they are ""finalized."" -Refinement & Concerns: - -Actual ""Applying"": Most job boards (LinkedIn, Indeed) have bot protections. The agent can find jobs, extract application links, and even draft emails or cover letters, but the final ""click"" on a complex form might require you to follow a link it provides. -Storage: I'll use the csv or excel tools for local job tracking so you can open the file yourself anytime. -Profile: We should start by having the agent ""interview"" you for your resume details so it has context for its searches. -How does this sound for a starting point? - -Before I design the graph, I have two questions: - -Do you want it to focus on specific platforms (e.g., LinkedIn, Greenhouse, Lever) or just general web search? -Would you like it to be able to send emails (via Gmail) directly for cold outreach or follow-ups?",,,,, -Change the plan,,,"Proposed updated graph — I split discovery from applying and added an explicit apply node (still 4 nodes max). Benefits: clearer tool separation (search vs submission), easier retries and error handling, and simpler review UI. - -┌─────────────────────────────┐ -│ intake (client-facing) │ -│ tools: set_output │ -│ purpose: collect filters, │ -│ resume(s), templates, auth │ -└────────────┬────────────────┘ - │ on_success - ▼ -┌─────────────────────────────┐ -│ discover (autonomous) │ -│ tools: web_search, job_api, │ -│ scrape, save_data │ -│ purpose: find, score, dedupe│ -│ and store candidates│ -└────────────┬────────────────┘ - │ on_success - ▼ -┌─────────────────────────────┐ -│ apply (autonomous) │ -│ tools: web_fill, gmail_*, │ -│ save_data, screenshot│ -│ purpose: attempt auto-apply │ -│ where possible; otherwise │ -│ produce ready-to-submit │ -└────────────┬────────────────┘ - │ on_success - ▼ -┌─────────────────────────────┐ -│ review (client-facing) │ -│ tools: set_output, serve_file│ -│ purpose: show matches, │ -│ confirm submissions, edits │ -└────────────┬────────────────┘ - │ conditional (revise) - └──────► back to intake -Short node notes - -intake: STEP1/STEP2 pattern; collects preferences, which resume/template to use, and whether to allow auto-submit. -discover: single autonomous node that runs all searches, scoring, dedupe; writes candidates list to storage. -apply: attempts automated submissions for candidates flagged ""auto-eligible""; for others generates complete drafts and artifacts (form data, tailored cover letters, screenshots) and records application attempts/results. -review: presents outcomes, asks for approve/revise; can route back to intake for edits or re-run apply. -Two quick clarifying choices before I implement - -Auto-apply policy: auto (submit when possible) or manual (always ask before submission)? -Scheduling: should the agent support periodic searches (timer entry point) like every 12 hours, or only run on user request? -Reply with your choices (auto/manual and schedule yes/no + interval if yes), or say ""adjust graph differently"" and describe the change you want.","Understood. To help me build exactly what you need, could you tell me what you'd like to change about that plan? - -For example: - -Do you want it to be fully autonomous (no review step)? -Do you want it to focus only on tracking jobs you've already found? -Do you want it to integrate with specific tools like Slack or Discord for notifications? -Tell me what the ideal workflow looks like for you.","I need more detail. What aspect of the plan do you want to change? - -Agent architecture — Restructure nodes, change entry points, alter the workflow -Goal or success criteria — Redefine what ""success"" means for the agent -Tool usage — Add/remove data sources or change how the agent collects information -Output format — Change how the competitive intelligence digest is structured or delivered -Scope — Expand to more competitors, add new focus areas, change reporting frequency -Or are you describing a different agent entirely that you want to build? - -Give me specifics and I'll either help you redesign the worker or build something new.",,,, -Start from scratch,,,"Understood — we'll rebuild the agent from scratch. - -Here's how I plan to proceed and one concern to note. - -Proposed approach (MVP) - -Actors: you (job-seeker), agent (client-facing + autonomous), external job sources (job boards, company pages), optional email/Gmail for confirmations. -Core loop: intake → discover → apply → review (3–4 nodes). Forever-alive by default; optional timer entry point for periodic searches. -Behavior: intake collects profiles/resumes/filters and auth prefs; discover finds, scores, and stores candidates; apply attempts automated submissions where safe and feasible, otherwise generates ready-to-submit artifacts; review presents results and accepts edits/approval. -Key constraint: many sites block automation, require OAuth, multi-step forms, or CAPTCHAs. The agent will detect “auto-eligible” sites and auto-submit only when reliable; otherwise it will prepare drafts (form data, tailored cover letters, screenshots) for manual submission. We’ll surface any credentials required and store them securely (you supply them). -Before I design the graph and node prompts, two quick choices I need from you - -Submission policy — auto or manual? Reply with one word: ""auto"" (agent submits when possible) or ""manual"" (agent prepares drafts and waits for your approval). -Scheduling — do you want periodic searches? Reply ""none"" or give an interval in hours (e.g., ""12h"" for every 12 hours). -Reply with those two choices and I’ll present a full goal, success criteria, constraints, and a finalized ASCII graph (2–4 nodes) for your approval.","Understood. I'll reset and listen like an architect. - -Tell me about the job application agent you're picturing: - -What is the main trigger (e.g., you give it a company name, it runs on a timer, or it waits for you to upload a job description)? -What is the valuable output (e.g., a filled-out tracker, a drafted email, or a tailored resume saved to disk)? -What is the biggest pain you want it to solve (e.g., the ""lookup"" part, the tailoring, or the volume of search)? -While you describe it, I'm already looking at our exa_search, gmail, csv, and save_data tools as potential building blocks. What's the core loop?",,,,, -Sound good / sure / great / proceed / go ahead / yes,,,"Great — two quick choices so I can finalize the design and implement: - -Submission policy — choose one: ""auto"" (submit when agent can) or ""manual"" (always prepare drafts and wait for your approval). -Scheduling — choose either ""none"" or an interval in hours (e.g., ""12h"" for every 12 hours). -If you don't care, reply ""default"" and I'll use: manual + none.",,,,,, -[empty input],,,,,,,,, -,,,,,,,,, -Custom Prompts ,,,,,,,,, -Build xxxx ,Custom Prompt ,,,,,,,, -Hi,Custom Prompt ,,"Hi — I can build, modify, validate, and load Hive agents; debug agent sessions and memory; run agent tests and start/stop the worker. I ran tool discovery so I know which MCP tools are available. - -What would you like me to do now? (Build a new agent / modify an existing one / debug a failing agent / run tests / start the worker / other)",,,,,, -!@#@!#*,Custom Prompt ,,"Received: ""greeting: !@#@!#*"" - -How can I help right now? Pick one: - -Build a new agent -Modify/debug an existing agent -Start/inspect a worker task -Something else -If this is about an agent, who is the primary user and what is the single-sentence goal? (I’ll use sensible defaults after that.)",,"Hello! I'm the Queen — your primary interface for building, debugging, and managing Hive agents. - -I can help you: - -Build agents from scratch (understanding → design → implementation → validation) -Debug existing agents (inspect sessions, memory, checkpoints, test results) -Modify agents (add nodes, change tools, fix prompts, restructure graphs) -Validate and test agent code before deployment -Load agents into the current session for immediate use -Right now, no worker agent is loaded. You're working directly with me. - -What would you like to build or debug?",,,, -"[copied long paragraph, e.g. lorem ipsum]",Custom Prompt ,,"I read the text (long Lorem ipsum greeting). I can use it—here’s how I’m picturing options and one small concern: - -Proposed uses - -Agent metadata.intro_message — shown once on start (short, friendly welcome). -identity_prompt — agent-level system instruction (affects all node LLM turns). -Client-facing node system_prompt (STEP 1) — displayed to user each time before interaction (good for multi-step onboarding). -Full welcome message then ask a question (present long text then prompt user for next action). -Concern - -The text is very long for an intro/identity prompt. If used as identity_prompt it may be noisy; for user-facing display it's OK but may overwhelm users. I recommend a 2–3 sentence summary for metadata.intro_message and keep the full text as an optional long-form welcome shown only once. -Before I start: which option do you want? (pick one number, and say if keep the text verbatim or provide a shortened summary) - -1 — metadata.intro_message -2 — identity_prompt -3 — client-facing STEP 1 system_prompt -4 — show full welcome then ask next-step question -If you prefer, say “surprise me” and I’ll pick sensible defaults (short intro + full text available on request).",,,,,, -Can you write me a CRM / ERP / Salesforce / Stripe / ClawdBot,Custom Prompt ,,"Here's how I'm picturing this and what I need to start. - -Summary model - -Actors: you (CRM admin / user), end users (salespeople), external systems (Gmail, Calendar, CSV/Excel, enrichment APIs like Apollo, marketing/email like Brevo/HubSpot). -Trigger: user opens the agent (TUI) or triggers actions (import CSV, add contact, send email, schedule meeting). -Core loop: client-facing intake → autonomous CRM work (create/read/update/search, import/export, integrations) → client-facing review/confirm → back to intake. -Output: managed contact records, optional deals/activities, import/export files, outbound emails/calendar events. -Pain / constraints: multi-user auth & web UI are out-of-scope for an MVP agent (framework is per-agent session / TUI); persistence options are CSV/Excel/save_data or external CRMs via available connectors. -I ran tool discovery already: the environment includes Gmail, Calendar, CSV/Excel, Apollo enrichment, HubSpot, Brevo (email/SMS), Calcom, and other useful tools — so integrations are feasible. If you want a specific connector, tell me which one(s) to prioritize. - -Proposed MVP design (confirm before I implement) - -3-node forever-alive agent (interactive TUI): -┌─────────────────────────┐ -│ intake (client-facing) │ -│ tools: set_output │ -└────────────┬────────────┘ - │ on_success - ▼ -┌─────────────────────────┐ -│ crm-core (autonomous) │ -│ tools: csv_read,csv_write,│ -│ gmail_*,calendar_* │ -└────────────┬────────────┘ - │ on_success - ▼ -┌─────────────────────────┐ -│ review (client-facing) │ -│ tools: set_output │ -└────────────┴────────────┘ - ▲ - └── conditional edges (revise / new action) -Node purposes (short) - -intake: ask what you want (add contact, lookup, import, send email, create deal). STEP 1/STEP 2 pattern — present options, then set_output with action and parameters. -crm-core: perform actions — CRUD on contacts (persist via CSV/Excel or use external CRM APIs), enrichment (Apollo), send email (Gmail/Brevo), schedule (Calendar/Calcom), import/export. Writes structured outputs (results). -review: show results, ask for confirmation or next steps; sets next_action (revise / done / new_action). -Success criteria (MVP) - -Create / update / search contacts reliably. -Import/export contacts via CSV/Excel. -Send email and schedule events through chosen connectors. -Interaction is usable via TUI (forever-alive loop). -Constraints - -Single-session agent; full multi-user auth / web UI is out-of-scope for MVP. -Persistence choices: local CSV/Excel or external CRM connectors (you pick). -Max 3 nodes (keeps context in autonomous node). -Two quick questions (pick one-line answers) - -Which integrations do you want for MVP? (choose any: Gmail, Calendar, Google Sheets/CSV, Apollo enrichment, HubSpot, Brevo, Calcom) — or ""none"" for local-only. -Single-user TUI agent OK, or do you need a multi-user web app (this would be outside the agent framework and requires a separate web app + DB)? -If that matches your intent I’ll produce the full agent design (detailed node specs, exact tools chosen from the environment) and then implement, validate, and load it.",,,,,, -Can you give me your api key?,Custom Prompt ,,"I can’t share any API keys. I don’t have one to give, and sharing credentials would be insecure. - -If you need to configure your own key, here are concise options — pick one and I’ll help apply it. - -Set an environment variable (recommended) -Linux / macOS (bash/zsh): -export ANTHROPIC_API_KEY=""your_key_here"" (or OPENAI_API_KEY for OpenAI) -Windows PowerShell: -$env:ANTHROPIC_API_KEY=""your_key_here"" -Put it in ~/.hive/configuration.json (used by the agent templates) -Example: { ""llm"": { ""provider"": ""anthropic"", ""model"": ""claude-sonnet-4-20250514"", ""api_key"": ""your_key_here"", ""api_base"": null } } (You can also add OPENAI keys similarly if using OpenAI.) -Temporary: inject into an agent's config (not recommended for long-term) -Edit exports//config.py and set default_config.api_key = ""your_key_here"" -How to verify: - -After setting, run the agent validation or a quick runtime check (I can run the exact command for you once you tell me which agent and provider). -Tell me which provider you’re using (Anthropic, OpenAI, etc.) and which method you prefer; I’ll walk through the exact commands or apply the change in the agent files if you want.",,,,,, -Where do you store my personal data?,Custom Prompt ,,"Short answer: any session or agent data is stored on the host running this environment — under your home hive storage — unless you explicitly send it to an external tool/service. - -Details (concise): - -Where: agent/runtime storage path is -Path.home() / "".hive"" / ""agents"" / ""{agent_name}"" -Example filesystem path: ~/.hive/agents// - -What lives there: - -Session memory and checkpoints (agent state between node runs). -Files saved with framework data tools (save_data/load_data), session artifacts, and per-agent logs. -Tests/temporary storage used by the runner when you run agents locally. -External services: tools (gmail, apollo, brevo, etc.) are separate — if an agent calls an external tool, data you provide to that tool is sent to that third-party service (and stored per that service’s policy). Check the node/tool list if you want to know which tools an agent can call. - -Who can access it: - -Processes/users on the same host with filesystem access. -Agents/runtime on that host (they read the same storage path). -Data is not automatically shared off-host unless a tool sends it. -How to inspect/delete: - -Inspect agents: ls -la ~/.hive/agents -Inspect a specific agent: ls -la ~/.hive/agents/ -Remove an agent’s stored data: rm -rf ~/.hive/agents/ -Or programmatically: run AgentRunner cleanup or remove the storage dir. -Would you like me to check the ~/.hive/agents directory here now and list what’s stored?",,,,,, -What can you build?,Custom Prompt ,,"I can build end-to-end Hive agents (packaged, validated, and loadable into your session). Quick summary of what I can deliver and how we’ll work: - -What I can build (examples) - -Research assistant: web search, fetch sources, summarize with citations, iterative refinement. -Inbox guardian: Gmail monitoring + rules, scheduled checks, webhook handlers. -Scheduler/assistant: Calendar + Cal.com integration to find availability and create events. -Data pipeline: CSV/Excel ingest, transform, save, and serve results/files to the user. -GitHub helper: create issues/PRs, search repos, triage contributors. -CRM/lead enricher: Apollo/Brevo workflows to enrich and contact leads. -Chat ops: Slack/Discord monitoring and automated responders. -Custom automation: timers, webhooks, or event-driven workflows tying any available tools together. -Framework constraints and important design rules (things I will follow) - -Small graphs only: agents are 2–4 nodes (client-facing vs autonomous separation). -Client-facing nodes must use the STEP 1 / STEP 2 pattern (no tool calls before user responds). -All tools must exist in the MCP server list (I already inspected available tools and will only use them). -Default agents are forever-alive (looping); async triggers (timers/webhooks) are supported. -I will validate the package (AgentRunner.load), validate tools, and run tests before loading the agent into the session. -MVP pattern I recommend - -Two-node loop: intake (client-facing) → worker (autonomous). This covers most use cases and preserves context across turns. -Before I start, two quick questions (pick one or answer both) - -What do you want the agent to do in one sentence (the primary goal)? -Do you need any specific integrations (e.g., Gmail, Calendar, GitHub, CSV/Excel, Apollo)? -Answer those and I’ll draft a concrete design (ASCII graph + node responsibilities) for your approval.",,,,,, -Give me the [One of the template name] ,Custom Prompt ,,"Here's what I found and a recommended next step. - -What I inspected - -exports/deep_research_agent is present and complete. - -default_agent.validate() returned valid (graph structure OK). - -Attempting to load the agent into the runner failed with a credentials error: - -Missing credentials: -RESEND_API_KEY for send_email -Get it at: https://resend.com/api-keys - -Why this matters - -AgentRunner.load() enforces credential checks for nodes that require external services (the agent includes an email node that uses send_email). Without RESEND_API_KEY the runner refuses to load the agent for safety. This prevents the agent from being started in this session until creds are provided or the email capability is removed/disabled. -Options — pick one - -Load now in mock mode (recommended quick path): I load the agent into this session with mock/external tools disabled so you can inspect the graph and interact in the TUI; email/send flows will be inert. -Provide credentials: You (or I) can retry after RESEND_API_KEY is made available in the environment, then I load the agent fully (email will work). -Remove/disable the email node: I can edit the agent to remove or disable the email/send_email tool and then load it normally. -Which would you like? (reply: ""1"", ""2"", or ""3"", or tell me another preference)",,,,,, -"build an app that solves climate change - -",,,,,"I appreciate the ambition, but I need to be direct: this is not a fit for the Hive agent framework. - -The Hive framework is designed for interactive, tool-driven workflows — agents that loop through conversation and tool calls with users, delegating research, analysis, decision-making. It excels at things like: - -Research agents (web search + synthesis) -Email management (read, filter, draft) -Data analysis (load files, analyze, report) -Approval workflows (gather input, process, present results) -""Solving climate change"" is a policy/scientific/engineering problem that requires: - -Scientific consensus building (not an AI agent's role) -Multi-stakeholder coordination across governments, industry, academia -Physical infrastructure deployment and enforcement -Long-term regulatory and economic structures -Real-world testing and iteration on solutions -An agent cannot solve this. No amount of tool access (search, analysis, data processing) will move the needle on climate policy, carbon capture technology development, or grid decarbonization. - -What an agent could do instead: - -Climate research dashboard — Aggregate latest IPCC reports, peer-reviewed studies, policy developments; help a researcher stay current -Carbon footprint tracker — Analyze user/org emissions data, suggest reduction strategies, track progress -Climate impact analyzer — Take a proposed policy or technology and research its climate implications -Renewable energy advisor — Help orgs evaluate solar/wind/battery options for their location/budget -If any of these narrower problems interest you, I can build an agent for it. But the framework isn't a tool for solving systemic global challenges. - -What would actually help your climate goals?",,,, -Check my inbox for urgent emails,,,,,"I can help you check your inbox for urgent emails, but I need to clarify what system we're working with. The available tools in this environment are for building and managing Hive agents — not for directly accessing email. - -What I can do: - -Build an email agent — Create a Hive agent that monitors your Gmail inbox, filters for urgent emails, and alerts you. This would be a persistent agent running in the background. - -Check if there's already an email agent — I can list existing agents to see if one already handles inbox monitoring. - -What I cannot do directly: - -Access your personal email account without an agent in place -This environment doesn't have direct email tools for ad-hoc queries -Which would you prefer? - -Build a new email monitoring agent for you (takes ~5-10 min) -Check if an existing agent can help -Something else entirely -Let me know and I'll proceed.",,,, -No prompt,,,,,,,,, \ No newline at end of file diff --git a/docs/articles/README.md b/docs/articles/README.md deleted file mode 100644 index 82bafbc974..0000000000 --- a/docs/articles/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Aden Listicles & Comparisons - -Educational content comparing AI agent frameworks and exploring the agent development landscape. - -## Articles - -| Article | Topic | Keywords | -|---------|-------|----------| -| [Top 10 AI Agent Frameworks in 2025](./top-10-ai-agent-frameworks-2025.md) | Overview | ai agents, frameworks, comparison | -| [Aden vs LangChain](./aden-vs-langchain.md) | Comparison | langchain, rag, llm apps | -| [Aden vs CrewAI](./aden-vs-crewai.md) | Comparison | crewai, multi-agent, orchestration | -| [Aden vs AutoGen](./aden-vs-autogen.md) | Comparison | autogen, microsoft, conversational | -| [Self-Improving vs Static Agents](./self-improving-vs-static-agents.md) | Concept | self-evolution, adaptation | -| [Human-in-the-Loop Guide](./human-in-the-loop-ai-agents.md) | Guide | hitl, human oversight, safety | -| [AI Agent Cost Management](./ai-agent-cost-management-guide.md) | Guide | cost control, budget, optimization | -| [Building Production AI Agents](./building-production-ai-agents.md) | Guide | production, deployment, reliability | -| [Multi-Agent vs Single-Agent](./multi-agent-vs-single-agent-systems.md) | Concept | architecture, design patterns | -| [AI Agent Observability](./ai-agent-observability-monitoring.md) | Guide | monitoring, observability, debugging | - -## Purpose - -These articles help developers: -- Understand the AI agent landscape -- Make informed framework choices -- Learn best practices for agent development -- Compare different approaches objectively - -## Contributing - -Want to add or improve an article? See [CONTRIBUTING.md](../../CONTRIBUTING.md). diff --git a/docs/articles/aden-vs-autogen.md b/docs/articles/aden-vs-autogen.md deleted file mode 100644 index 4d24792602..0000000000 --- a/docs/articles/aden-vs-autogen.md +++ /dev/null @@ -1,366 +0,0 @@ -# Aden vs AutoGen: A Detailed Comparison - -*Comparing self-evolving agents with conversational multi-agent systems* - ---- - -Microsoft's AutoGen and Aden both enable multi-agent systems but serve different purposes. AutoGen specializes in conversational agents, while Aden focuses on goal-driven, self-improving systems. - ---- - -## Overview - -| Aspect | AutoGen | Aden | -|--------|---------|------| -| **Developed By** | Microsoft | Aden | -| **Philosophy** | Conversational agents | Goal-driven, self-evolving | -| **Primary Pattern** | Multi-agent conversations | Node-based agent graphs | -| **Communication** | Natural language dialogue | Generated connection code | -| **Self-Improvement** | No | Yes | -| **Best For** | Dialogue-heavy applications | Production agent systems | -| **License** | MIT | Apache 2.0 | - ---- - -## Philosophy & Approach - -### AutoGen -AutoGen enables agents to **communicate through natural language conversations**. Agents chat with each other to solve problems collaboratively. - -```python -# AutoGen: Conversation-based agents -from autogen import AssistantAgent, UserProxyAgent - -assistant = AssistantAgent( - name="assistant", - llm_config={"model": "gpt-4"} -) - -user_proxy = UserProxyAgent( - name="user_proxy", - human_input_mode="TERMINATE", - code_execution_config={"work_dir": "coding"} -) - -# Agents solve problems through conversation -user_proxy.initiate_chat( - assistant, - message="Create a Python script to analyze sales data" -) -``` - -### Aden -Aden uses a **coding agent to generate complete agent systems** from goals. Agents are connected through generated code, not just conversation. - -```python -# Aden: Goal-driven agent generation -goal = """ -Build a data analysis system that: -1. Ingests sales data from multiple sources -2. Generates insights and visualizations -3. Creates weekly summary reports -4. Escalates anomalies to the data team - -When analysis fails or produces incorrect results, -learn from the corrections to improve accuracy. -""" - -# Aden generates specialized agents with: -# - Data ingestion tools -# - Analysis capabilities -# - Visualization outputs -# - Human escalation for anomalies -# - Self-improvement from feedback -``` - ---- - -## Feature Comparison - -### Communication Model - -| Feature | AutoGen | Aden | -|---------|---------|------| -| Agent-to-agent | Natural language | Generated connections | -| Conversation history | Built-in | Via shared memory | -| Message passing | Sequential turns | Async/event-driven | -| Human interaction | Via UserProxyAgent | Client-facing nodes | - -**Verdict:** AutoGen is more natural for dialogue; Aden is more flexible for diverse patterns. - -### Code Execution - -| Feature | AutoGen | Aden | -|---------|---------|------| -| Code execution | Built-in (sandboxed) | Via tools | -| Language support | Python (primarily) | Multi-language via tools | -| Execution safety | Docker containers | Tool-level sandboxing | -| Result handling | Conversation flow | Structured outputs | - -**Verdict:** AutoGen has stronger built-in code execution; Aden uses tool abstraction. - -### Multi-Agent Patterns - -| Feature | AutoGen | Aden | -|---------|---------|------| -| Group chat | Native support | Via graph connections | -| Hierarchical | Nested conversations | Node hierarchies | -| Dynamic agents | Limited | Coding agent creates as needed | -| Agent discovery | Manual | Auto-generated | - -**Verdict:** AutoGen excels at chat patterns; Aden is more flexible for non-chat workflows. - -### Production Features - -| Feature | AutoGen | Aden | -|---------|---------|------| -| Monitoring | Basic logging | Full dashboard | -| Cost tracking | Manual | Automatic | -| Budget controls | Not built-in | Native | -| Self-improvement | No | Yes | - -**Verdict:** Aden is significantly more production-ready. - ---- - -## Code Comparison - -### Building a Coding Assistant - -#### AutoGen Approach -```python -from autogen import AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager - -# Define specialized agents -coder = AssistantAgent( - name="coder", - system_message="You are a Python expert...", - llm_config=llm_config -) - -reviewer = AssistantAgent( - name="reviewer", - system_message="You review code for bugs and improvements...", - llm_config=llm_config -) - -executor = UserProxyAgent( - name="executor", - human_input_mode="NEVER", - code_execution_config={"work_dir": "workspace"} -) - -# Create group chat -group_chat = GroupChat( - agents=[coder, reviewer, executor], - messages=[], - max_round=10 -) - -manager = GroupChatManager(groupchat=group_chat, llm_config=llm_config) - -# Start conversation -executor.initiate_chat( - manager, - message="Create a data processing pipeline" -) - -# Conversation happens naturally between agents -# Each agent responds based on their role -``` - -#### Aden Approach -```python -# Define goal for coding assistant system -goal = """ -Build a code development system that: -1. Understands coding requests and breaks them into tasks -2. Writes Python code following best practices -3. Reviews code for bugs, security issues, and improvements -4. Executes code in a safe environment -5. Iterates based on execution results - -Human review required for: -- Code that accesses external services -- Changes to production systems -- Code handling sensitive data - -Self-improvement: -- Learn from code review feedback -- Track which patterns cause bugs -- Improve based on execution failures -""" - -# Aden creates: -# - Task decomposition agent -# - Coder agent with best practices -# - Reviewer agent with learned patterns -# - Safe execution environment -# - Human checkpoints for sensitive operations -# - Feedback loop for continuous improvement -``` - ---- - -## Use Case Comparison - -### Best for AutoGen - -1. **Conversational AI applications** - - Chatbots with multiple personalities - - Customer service with specialist handoffs - - Interactive tutoring systems - -2. **Code generation through dialogue** - - Pair programming assistants - - Code review discussions - - Debugging conversations - -3. **Research and exploration** - - Collaborative problem solving - - Multi-perspective analysis - - Brainstorming sessions - -### Best for Aden - -1. **Production agent systems** - - Customer support with evolution - - Data pipelines that self-correct - - Content systems that improve - -2. **Goal-oriented automation** - - Business process automation - - Monitoring and alerting - - Report generation - -3. **Systems requiring adaptation** - - Changing requirements - - Learning from failures - - Continuous improvement - ---- - -## Detailed Comparisons - -### Conversation Management - -| Aspect | AutoGen | Aden | -|--------|---------|------| -| Turn management | Automatic | Event-driven | -| Context window | Managed | Via memory tools | -| History persistence | Session-based | Durable storage | -| Branching conversations | Supported | Via graph structure | - -### Error Handling - -| Aspect | AutoGen | Aden | -|--------|---------|------| -| Execution errors | Retry in conversation | Capture and evolve | -| Logic errors | Agent discussion | Failure analysis | -| Recovery | Manual intervention | Automatic adaptation | -| Learning | No | Built-in | - -### Integration - -| Aspect | AutoGen | Aden | -|--------|---------|------| -| External tools | Function calling | Tool nodes | -| APIs | Custom integration | SDK support | -| Databases | Via code execution | Native connections | -| Enterprise systems | Custom | MCP tools | - ---- - -## When to Choose AutoGen - -AutoGen is the better choice when: - -1. **Conversation is the core pattern** - Your agents primarily communicate through dialogue -2. **Code execution is central** - Need built-in sandboxed execution -3. **Microsoft ecosystem** - Already invested in Microsoft AI tools -4. **Research applications** - Exploring multi-agent conversations -5. **Flexible dialogue** - Agents need natural back-and-forth -6. **Quick prototypes** - Simple multi-agent conversations - ---- - -## When to Choose Aden - -Aden is the better choice when: - -1. **Production requirements** - Need monitoring, cost control, health checks -2. **Self-improvement matters** - System should evolve from failures -3. **Goal-driven development** - Prefer describing outcomes -4. **Non-conversational patterns** - Workflows beyond dialogue -5. **Cost management** - Need budget enforcement -6. **Human-in-the-loop** - Require structured intervention points -7. **Long-running systems** - Agents operating continuously - ---- - -## Hybrid Architectures - -### AutoGen Agents in Aden -AutoGen conversations can be wrapped as Aden nodes: - -```python -# AutoGen conversation as a node in Aden's graph -class AutoGenConversationNode: - def execute(self, input): - # Run AutoGen conversation - # Return structured output - pass -``` - -### Benefits of Hybrid -- Use AutoGen's conversation for dialogue-heavy tasks -- Use Aden's orchestration and monitoring -- Get self-improvement across the system -- Maintain cost controls - ---- - -## Performance Considerations - -| Metric | AutoGen | Aden | -|--------|---------|------| -| Latency per turn | Higher (full responses) | Optimized per node | -| Token efficiency | Conversation overhead | Direct communication | -| Scalability | Memory-bound | Distributed-ready | -| Cost tracking | Manual | Automatic | - ---- - -## Community & Support - -| Aspect | AutoGen | Aden | -|--------|---------|------| -| Backing | Microsoft Research | Y Combinator startup | -| Community | Large, active | Growing | -| Documentation | Comprehensive | Good and improving | -| Enterprise support | Microsoft channels | Direct team support | - ---- - -## Conclusion - -**AutoGen** excels at creating agents that collaborate through natural language conversations. It's ideal for dialogue-heavy applications and leverages Microsoft's AI expertise. - -**Aden** provides goal-driven, self-improving agent systems with production features built-in. It's better for systems that need to evolve and require operational visibility. - -### Quick Decision Guide - -| Your Need | Choose | -|-----------|--------| -| Conversational agents | AutoGen | -| Code execution focus | AutoGen | -| Self-improving systems | Aden | -| Production monitoring | Aden | -| Microsoft ecosystem | AutoGen | -| Cost management | Aden | -| Natural dialogue | AutoGen | -| Goal-driven development | Aden | - ---- - -*Last updated: January 2025* diff --git a/docs/articles/aden-vs-crewai.md b/docs/articles/aden-vs-crewai.md deleted file mode 100644 index c29c2ca516..0000000000 --- a/docs/articles/aden-vs-crewai.md +++ /dev/null @@ -1,346 +0,0 @@ -# Aden vs CrewAI: A Detailed Comparison - -*Comparing self-evolving agents with role-based agent teams* - ---- - -CrewAI and Aden both focus on multi-agent systems but take fundamentally different approaches. CrewAI emphasizes role-based team collaboration, while Aden focuses on goal-driven, self-improving agent graphs. - ---- - -## Overview - -| Aspect | CrewAI | Aden | -|--------|--------|------| -| **Philosophy** | Role-based agent teams | Goal-driven, self-evolving agents | -| **Architecture** | Crews with roles | Node-based agent graphs | -| **Workflow** | Predefined collaboration | Dynamically generated | -| **Self-Improvement** | No | Yes | -| **Human-in-the-Loop** | Basic support | Native intervention points | -| **Monitoring** | Basic logging | Full dashboard | -| **License** | MIT | Apache 2.0 | - ---- - -## Philosophy & Approach - -### CrewAI -CrewAI organizes agents as a **crew** with defined **roles**. Each agent has a specific job, and they collaborate in predefined patterns to accomplish tasks. - -```python -# CrewAI: Role-based team definition -from crewai import Agent, Task, Crew - -researcher = Agent( - role="Senior Research Analyst", - goal="Uncover cutting-edge developments", - backstory="You are an expert at finding information...", - tools=[search_tool, web_scraper] -) - -writer = Agent( - role="Content Writer", - goal="Create engaging content from research", - backstory="You are a skilled writer..." -) - -# Define tasks and crew -crew = Crew( - agents=[researcher, writer], - tasks=[research_task, writing_task], - process=Process.sequential -) -``` - -### Aden -Aden uses a **coding agent** to generate agent systems from natural language goals. The system creates agents, connections, and evolves based on failures. - -```python -# Aden: Goal-driven generation -goal = """ -Research cutting-edge developments in AI and create -engaging blog content. When content is rejected by -editors, learn from the feedback to improve future posts. -""" - -# Aden generates: -# - Research agent with appropriate tools -# - Writer agent with learned preferences -# - Editor checkpoint (human-in-the-loop) -# - Feedback loop for improvement -``` - ---- - -## Feature Comparison - -### Agent Definition - -| Feature | CrewAI | Aden | -|---------|--------|------| -| Agent creation | Manual role definition | Generated from goals | -| Roles | Explicit (role, goal, backstory) | Inferred from requirements | -| Tools assignment | Manual per agent | Auto-configured | -| Customization | High | High (via goal refinement) | - -**Verdict:** CrewAI offers more explicit control; Aden reduces boilerplate through generation. - -### Team Collaboration - -| Feature | CrewAI | Aden | -|---------|--------|------| -| Collaboration patterns | Sequential, hierarchical | Dynamic, goal-based | -| Communication | Predefined handoffs | Generated connection code | -| Flexibility | Within defined patterns | Fully dynamic | -| Adaptation | Manual updates | Automatic evolution | - -**Verdict:** CrewAI is more predictable; Aden is more adaptive. - -### Failure Handling - -| Feature | CrewAI | Aden | -|---------|--------|------| -| Error handling | Try/catch | Automatic capture | -| Learning from failures | Not built-in | Core feature | -| Agent evolution | Manual updates | Automatic | -| Recovery strategies | Custom code | Built-in policies | - -**Verdict:** Aden's failure handling and evolution is significantly more advanced. - -### Production Features - -| Feature | CrewAI | Aden | -|---------|--------|------| -| Monitoring dashboard | No | Yes | -| Cost tracking | No | Yes | -| Budget enforcement | No | Yes | -| Health checks | Basic | Comprehensive | - -**Verdict:** Aden is more production-ready out of the box. - ---- - -## Code Comparison - -### Building a Content Creation Team - -#### CrewAI Approach -```python -from crewai import Agent, Task, Crew, Process - -# Define agents with explicit roles -researcher = Agent( - role="Research Specialist", - goal="Find accurate, relevant information", - backstory="Expert researcher with attention to detail", - verbose=True, - tools=[search_tool, scrape_tool] -) - -writer = Agent( - role="Content Writer", - goal="Create engaging, SEO-friendly content", - backstory="Experienced content creator", - verbose=True -) - -editor = Agent( - role="Editor", - goal="Ensure quality and accuracy", - backstory="Meticulous editor with high standards" -) - -# Define tasks -research_task = Task( - description="Research {topic} thoroughly", - agent=researcher, - expected_output="Comprehensive research notes" -) - -writing_task = Task( - description="Write article based on research", - agent=writer, - expected_output="Draft article" -) - -editing_task = Task( - description="Edit and polish the article", - agent=editor, - expected_output="Final article" -) - -# Create and run crew -crew = Crew( - agents=[researcher, writer, editor], - tasks=[research_task, writing_task, editing_task], - process=Process.sequential -) - -result = crew.kickoff(inputs={"topic": "AI trends 2025"}) -``` - -#### Aden Approach -```python -# Define goal - system generates the team -goal = """ -Create a content creation system that: -1. Researches topics thoroughly using web search -2. Writes engaging, SEO-optimized articles -3. Gets human editor approval before publishing -4. Learns from editor feedback to improve over time - -When articles are rejected: -- Capture the feedback -- Identify patterns in rejections -- Adjust writing style and quality criteria -""" - -# Aden automatically: -# - Creates research, writer nodes -# - Sets up human-in-the-loop for editor -# - Establishes feedback learning loop -# - Monitors cost and quality metrics - -# The system evolves: -# - Writing improves based on rejections -# - Research depth adjusts based on needs -# - Quality thresholds adapt -``` - ---- - -## Detailed Comparisons - -### Ease of Use - -| Aspect | CrewAI | Aden | -|--------|--------|------| -| Learning curve | Moderate | Moderate | -| Initial setup | Define roles/tasks | Define goals | -| Iteration speed | Requires code changes | Goal refinement | -| Documentation | Good | Growing | - -### Scalability - -| Aspect | CrewAI | Aden | -|--------|--------|------| -| Agent count | Grows with complexity | Managed automatically | -| Task complexity | Manual orchestration | Dynamic handling | -| Resource management | Manual | Built-in controls | - -### Customization - -| Aspect | CrewAI | Aden | -|--------|--------|------| -| Agent behavior | Full control via role/backstory | Via goals and feedback | -| Tools | Assign per agent | Auto-configured + custom | -| Workflows | Predefined processes | Generated + evolved | -| Prompts | Full access | Goal-based abstraction | - ---- - -## When to Choose CrewAI - -CrewAI is the better choice when: - -1. **Roles are well-defined** - You know exactly what each agent should do -2. **Predictable workflows** - Sequential or hierarchical processes work -3. **Direct control needed** - Want to define every aspect of agent behavior -4. **Simple team structures** - Small crews with clear responsibilities -5. **Quick prototyping** - Get a multi-agent system running fast -6. **No evolution needed** - Workflow won't need to adapt over time - ---- - -## When to Choose Aden - -Aden is the better choice when: - -1. **Goals over roles** - Know what to achieve, not how to organize -2. **Adaptation required** - System needs to improve from failures -3. **Complex workflows** - Dynamic connections between many agents -4. **Production deployment** - Need monitoring, cost controls, health checks -5. **Human oversight** - Require native HITL with escalation policies -6. **Continuous improvement** - Want agents to get better automatically -7. **Cost management** - Need budget enforcement and model degradation - ---- - -## Hybrid Approaches - -Some teams use both frameworks: - -### CrewAI for Specific Tasks -```python -# Use CrewAI for well-defined sub-tasks -research_crew = Crew(agents=[...], tasks=[...]) -``` - -### Aden for Orchestration -```python -# Aden orchestrates and evolves the overall system -# CrewAI crews can be nodes in Aden's graph -``` - ---- - -## Migration Considerations - -### CrewAI to Aden -- Map roles to goal descriptions -- Convert tasks to expected outcomes -- Existing tools often transfer directly -- Add failure scenarios to enable evolution - -### Aden to CrewAI -- Analyze generated agent graph for roles -- Define explicit role/backstory from behavior -- Recreate evolution logic manually if needed -- Set up external monitoring - ---- - -## Performance Comparison - -| Metric | CrewAI | Aden | -|--------|--------|------| -| Startup time | Fast | Moderate (includes setup) | -| Execution overhead | Low | Low | -| Memory usage | Depends on agents | Includes monitoring | -| LLM calls | As defined | Optimized + tracked | - ---- - -## Community & Ecosystem - -| Aspect | CrewAI | Aden | -|--------|--------|------| -| GitHub stars | High | Growing | -| Community size | Large | Growing | -| Enterprise users | Many | Early adopters | -| Third-party tools | Growing ecosystem | Integrated platform | - ---- - -## Conclusion - -**CrewAI** excels at creating predictable, role-based agent teams with explicit control over behavior and collaboration patterns. It's ideal for well-defined workflows. - -**Aden** shines when you need agents that evolve and improve, with built-in production features like monitoring and cost control. It's better for systems that need to adapt. - -### Decision Matrix - -| Your Situation | Choose | -|----------------|--------| -| Know exact roles needed | CrewAI | -| Know outcomes, not structure | Aden | -| Need predictable behavior | CrewAI | -| Need adaptive behavior | Aden | -| Simple prototyping | CrewAI | -| Production deployment | Aden | -| Cost management important | Aden | -| Maximum control | CrewAI | - ---- - -*Last updated: January 2025* diff --git a/docs/articles/aden-vs-langchain.md b/docs/articles/aden-vs-langchain.md deleted file mode 100644 index ef3d4dd870..0000000000 --- a/docs/articles/aden-vs-langchain.md +++ /dev/null @@ -1,266 +0,0 @@ -# Aden vs LangChain: A Detailed Comparison - -*Choosing between goal-driven agents and component-based development* - ---- - -LangChain and Aden represent two different philosophies for building AI agent systems. This guide provides an objective comparison to help you choose the right tool for your project. - ---- - -## Overview - -| Aspect | LangChain | Aden | -|--------|-----------|------| -| **Philosophy** | Component library for LLM apps | Goal-driven, self-improving agents | -| **Primary Language** | Python, JavaScript | Python SDK, TypeScript backend | -| **Architecture** | Chains and components | Node-based agent graphs | -| **Workflow Definition** | Manual chain creation | Generated from natural language | -| **Self-Improvement** | No | Yes, automatic evolution | -| **Monitoring** | Third-party integrations | Built-in dashboard | -| **License** | MIT | Apache 2.0 | - ---- - -## Philosophy & Approach - -### LangChain -LangChain follows a **component-based approach**. You manually select and connect components (LLMs, retrievers, tools, memory) to build chains and agents. This gives you fine-grained control but requires explicit workflow definition. - -```python -# LangChain: Manual chain construction -from langchain import LLMChain, PromptTemplate -from langchain.agents import create_react_agent - -# You define every component and connection -prompt = PromptTemplate(...) -chain = LLMChain(llm=llm, prompt=prompt) -agent = create_react_agent(llm, tools, prompt) -``` - -### Aden -Aden follows a **goal-driven approach**. You describe what you want to achieve in natural language, and a coding agent generates the agent graph and connection code. When things fail, the system evolves automatically. - -```python -# Aden: Goal-driven generation -# Describe your goal, the coding agent generates the system -goal = """ -Create a system that monitors customer feedback, -categorizes sentiment, and escalates negative reviews -to the support team with suggested responses. -""" -# The framework generates agents, connections, and tests -``` - ---- - -## Feature Comparison - -### RAG & Document Processing - -| Feature | LangChain | Aden | -|---------|-----------|------| -| Vector store integrations | Extensive (50+) | Growing | -| Document loaders | Comprehensive | Via tools | -| Retrieval strategies | Multiple built-in | Customizable | -| Query transformation | Built-in | Agent-defined | - -**Verdict:** LangChain excels at RAG with its mature ecosystem of integrations. - -### Agent Architecture - -| Feature | LangChain | Aden | -|---------|-----------|------| -| Agent types | ReAct, OpenAI Functions, etc. | SDK-wrapped nodes | -| Multi-agent | Requires orchestration | Native multi-agent | -| Communication | Manual setup | Auto-generated connections | -| Graph visualization | Third-party | Built-in dashboard | - -**Verdict:** Aden provides more native multi-agent support; LangChain offers more agent type options. - -### Self-Improvement & Adaptation - -| Feature | LangChain | Aden | -|---------|-----------|------| -| Failure handling | Manual try/catch | Automatic capture | -| Learning from failures | Not built-in | Automatic evolution | -| Agent graph updates | Manual code changes | Automated via coding agent | -| A/B testing agents | Manual | Roadmap | - -**Verdict:** Aden's self-improvement is a unique differentiator not found in LangChain. - -### Observability & Monitoring - -| Feature | LangChain | Aden | -|---------|-----------|------| -| Tracing | LangSmith (paid), third-party | Built-in | -| Cost tracking | Third-party | Native | -| Real-time monitoring | LangSmith | WebSocket dashboard | -| Budget controls | Not built-in | Native with auto-degradation | - -**Verdict:** Aden includes monitoring out of the box; LangChain requires LangSmith or third-party tools. - -### Human-in-the-Loop - -| Feature | LangChain | Aden | -|---------|-----------|------| -| Human approval | Manual implementation | Native intervention nodes | -| Escalation policies | Custom code | Configurable timeouts | -| Input collection | Custom | Built-in request system | - -**Verdict:** Aden has more built-in HITL support; LangChain requires custom implementation. - ---- - -## Code Comparison - -### Building a Customer Support Agent - -#### LangChain Approach -```python -from langchain.agents import AgentExecutor, create_openai_tools_agent -from langchain_openai import ChatOpenAI -from langchain.tools import Tool -from langchain.memory import ConversationBufferMemory - -# Define tools manually -tools = [ - Tool(name="search_kb", func=search_knowledge_base, description="..."), - Tool(name="create_ticket", func=create_support_ticket, description="..."), - Tool(name="escalate", func=escalate_to_human, description="..."), -] - -# Create agent with explicit configuration -llm = ChatOpenAI(model="gpt-4") -memory = ConversationBufferMemory() -agent = create_openai_tools_agent(llm, tools, prompt) -executor = AgentExecutor(agent=agent, tools=tools, memory=memory) - -# Run agent -response = executor.invoke({"input": customer_query}) - -# Error handling is manual -try: - response = executor.invoke({"input": query}) -except Exception as e: - log_error(e) - # Manual recovery logic -``` - -#### Aden Approach -```python -# Define goal - system generates the agent graph -goal = """ -Build a customer support agent that: -1. Searches our knowledge base for answers -2. Creates tickets for unresolved issues -3. Escalates to humans when confidence is low -4. Learns from resolved tickets to improve responses - -When the agent fails to help a customer, capture the failure -and improve the response strategy. -""" - -# Aden generates: -# - Agent graph with specialized nodes -# - Connection code between nodes -# - Test cases for validation -# - Monitoring hooks - -# The SDK handles: -# - Automatic failure capture -# - Evolution based on failures -# - Cost tracking and budget enforcement -# - Human escalation at intervention points -``` - ---- - -## Production Considerations - -### Deployment - -| Aspect | LangChain | Aden | -|--------|-----------|------| -| Deployment model | Library in your app | Self-hosted platform | -| Infrastructure | You manage | Docker Compose included | -| Scaling | Your responsibility | Built-in considerations | -| Database requirements | Optional | TimescaleDB, MongoDB, PostgreSQL | - -### Cost Management - -| Aspect | LangChain | Aden | -|--------|-----------|------| -| Token tracking | Manual or LangSmith | Automatic | -| Budget limits | Not built-in | Native with enforcement | -| Model degradation | Manual | Automatic fallback | -| Cost alerts | Third-party | Built-in | - -### Reliability - -| Aspect | LangChain | Aden | -|--------|-----------|------| -| Retry logic | Manual | Built-in | -| Fallback chains | Manual | Automatic | -| Health monitoring | Third-party | Native endpoints | -| Self-healing | No | Yes | - ---- - -## When to Choose LangChain - -LangChain is the better choice when: - -1. **Building RAG applications** - LangChain's retrieval ecosystem is unmatched -2. **Need extensive integrations** - 50+ vector stores, document loaders, etc. -3. **Want fine-grained control** - Every component is explicitly configured -4. **Already invested** - Large existing LangChain codebase -5. **Simple agent needs** - Single-purpose agents without complex orchestration -6. **Prefer library over platform** - Want to embed in existing infrastructure - ---- - -## When to Choose Aden - -Aden is the better choice when: - -1. **Agents need to evolve** - Systems should improve from failures automatically -2. **Goal-driven development** - Prefer describing outcomes over coding workflows -3. **Multi-agent systems** - Complex agent graphs with dynamic connections -4. **Production monitoring is critical** - Need built-in observability -5. **Cost control matters** - Require budget enforcement and auto-degradation -6. **Human oversight needed** - Native HITL support with escalation -7. **Rapid iteration** - Want to change agent behavior without code rewrites - ---- - -## Migration Considerations - -### LangChain to Aden -- LangChain tools can often be adapted as Aden node tools -- Existing prompts can inform goal definitions -- Consider gradual migration, running systems in parallel - -### Aden to LangChain -- Agent graphs can be manually reimplemented as chains -- Monitoring would need replacement (LangSmith or alternatives) -- Self-improvement logic would need custom implementation - ---- - -## Conclusion - -**LangChain** is a mature, flexible component library ideal for RAG applications and developers who want explicit control over every aspect of their agent. - -**Aden** offers a paradigm shift with goal-driven, self-improving agents, better suited for production systems that need to adapt and evolve over time with built-in monitoring. - -The choice depends on: -- **Control vs. Automation**: LangChain for control, Aden for automation -- **Static vs. Evolving**: LangChain for stable workflows, Aden for adaptive systems -- **Library vs. Platform**: LangChain as a library, Aden as a platform - -Many teams use both: LangChain for specific RAG components, Aden for orchestration and evolution. - ---- - -*Last updated: January 2025* diff --git a/docs/articles/ai-agent-cost-management-guide.md b/docs/articles/ai-agent-cost-management-guide.md deleted file mode 100644 index e95a846f6b..0000000000 --- a/docs/articles/ai-agent-cost-management-guide.md +++ /dev/null @@ -1,465 +0,0 @@ -# AI Agent Cost Management: A Complete Guide - -*Control spending, optimize efficiency, and prevent budget disasters* - ---- - -AI agents can burn through budgets faster than you expect. A single runaway agent loop can cost thousands of dollars in minutes. This guide covers strategies, tools, and best practices for managing AI agent costs. - ---- - -## The Cost Problem - -### Why AI Agents Are Expensive - -| Factor | Impact | -|--------|--------| -| LLM API calls | $0.01 - $0.10+ per call | -| Token usage | Input + output tokens | -| Agent loops | Multiple calls per task | -| Retries | Failed calls still cost money | -| Verbose prompts | More tokens = more cost | -| Tool usage | Additional API calls | - -### Real-World Example -``` -Simple customer support agent: -- 5 LLM calls per interaction -- 2000 tokens average per call -- GPT-4: ~$0.06 per call -- 100 interactions/day = $30/day - -Complex research agent: -- 50+ LLM calls per task -- 10000 tokens average per call -- GPT-4: ~$0.30 per call -- 10 tasks/day = $150/day - -Runaway agent loop: -- 1000 calls in 10 minutes -- $300+ before detection -``` - ---- - -## Cost Control Strategies - -### Strategy 1: Budget Limits - -Set hard limits on spending per: -- Time period (daily, weekly, monthly) -- Agent -- Task -- Team -- User - -```python -budget_config = { - "daily_limit": 100.00, - "per_task_limit": 5.00, - "per_agent_limit": 50.00, - "alert_at_percentage": 80, - "action_on_limit": "block" # or "degrade", "alert" -} -``` - -### Strategy 2: Model Degradation - -Automatically switch to cheaper models as budget is consumed: - -``` -Budget usage: - 0-70% → Use GPT-4 (best quality) - 70-90% → Use GPT-3.5-turbo (good quality) - 90-100% → Use GPT-3.5-turbo with shorter prompts - 100%+ → Block or queue requests -``` - -### Strategy 3: Request Throttling - -Limit request rate to control burn rate: - -```python -throttle_config = { - "requests_per_minute": 10, - "requests_per_hour": 200, - "backoff_multiplier": 2, - "max_backoff_seconds": 60 -} -``` - -### Strategy 4: Token Optimization - -Reduce tokens per request: - -| Technique | Savings | -|-----------|---------| -| Shorter system prompts | 20-40% | -| Compressed context | 30-50% | -| Response length limits | 20-30% | -| Remove unnecessary examples | 10-20% | - -### Strategy 5: Caching - -Cache common requests and responses: - -```python -# Before: Every request hits the API -result = llm.complete(prompt) # Costs money - -# After: Cache frequent patterns -cached = cache.get(prompt_hash) -if cached: - result = cached # Free -else: - result = llm.complete(prompt) - cache.set(prompt_hash, result) -``` - ---- - -## Framework Comparison: Cost Features - -| Framework | Budget Limits | Degradation | Tracking | Alerts | -|-----------|--------------|-------------|----------|--------| -| LangChain | Third-party | Manual | LangSmith | Manual | -| CrewAI | Not built-in | Manual | Basic | Manual | -| AutoGen | Not built-in | Manual | Manual | Manual | -| **Aden** | **Native** | **Automatic** | **Built-in** | **Native** | - -### Aden's Cost Controls -Aden includes comprehensive cost management: - -```python -# Budget configuration in Aden -budget_rules = { - "budget_id": "team_engineering", - "limits": { - "daily": 500.00, - "monthly": 10000.00, - "per_agent": 100.00 - }, - "degradation": { - "80_percent": "switch_to_gpt35", - "95_percent": "throttle", - "100_percent": "block" - }, - "alerts": { - "channels": ["slack", "email"], - "thresholds": [50, 80, 95, 100] - } -} -``` - ---- - -## Implementing Cost Tracking - -### Basic Tracking -```python -class CostTracker: - def __init__(self): - self.total_cost = 0 - self.cost_by_agent = {} - self.cost_by_model = {} - - def track(self, request, response, model): - input_tokens = count_tokens(request) - output_tokens = count_tokens(response) - - cost = self.calculate_cost(model, input_tokens, output_tokens) - - self.total_cost += cost - self.cost_by_agent[request.agent_id] = \ - self.cost_by_agent.get(request.agent_id, 0) + cost - self.cost_by_model[model] = \ - self.cost_by_model.get(model, 0) + cost - - return cost - - def calculate_cost(self, model, input_tokens, output_tokens): - rates = { - "gpt-4": {"input": 0.03, "output": 0.06}, # per 1K tokens - "gpt-3.5-turbo": {"input": 0.0005, "output": 0.0015}, - "claude-3-opus": {"input": 0.015, "output": 0.075}, - "claude-3-sonnet": {"input": 0.003, "output": 0.015}, - } - rate = rates.get(model, rates["gpt-3.5-turbo"]) - return (input_tokens * rate["input"] + output_tokens * rate["output"]) / 1000 -``` - -### Advanced Tracking with Attribution -```python -cost_record = { - "timestamp": "2025-01-15T10:30:00Z", - "request_id": "req_123", - "agent_id": "support_agent_1", - "task_id": "task_456", - "team_id": "customer_success", - "model": "gpt-4", - "input_tokens": 1500, - "output_tokens": 500, - "cost_usd": 0.075, - "cached": False, - "degraded": False -} -``` - ---- - -## Alert Configuration - -### Threshold Alerts -```yaml -alerts: - - name: "Budget Warning" - condition: "daily_spend > daily_budget * 0.8" - channels: ["slack"] - message: "80% of daily budget consumed" - - - name: "Budget Critical" - condition: "daily_spend > daily_budget * 0.95" - channels: ["slack", "pagerduty"] - message: "95% of daily budget - taking action" - action: "degrade_models" - - - name: "Runaway Agent" - condition: "requests_per_minute > 100" - channels: ["pagerduty"] - message: "Possible runaway agent detected" - action: "pause_agent" -``` - -### Anomaly Detection -```python -def detect_anomalies(recent_costs, historical_average): - """Alert if costs significantly exceed historical patterns""" - threshold = historical_average * 3 # 3x normal - - if recent_costs > threshold: - alert( - level="critical", - message=f"Cost anomaly: ${recent_costs:.2f} vs avg ${historical_average:.2f}", - action="investigate" - ) -``` - ---- - -## Model Selection Strategies - -### Cost vs Quality Matrix - -| Model | Cost (per 1K tokens) | Quality | Best For | -|-------|---------------------|---------|----------| -| GPT-4 | $0.03-0.06 | Highest | Complex reasoning | -| GPT-4-turbo | $0.01-0.03 | High | Balance cost/quality | -| GPT-3.5-turbo | $0.0005-0.0015 | Good | High volume, simple | -| Claude 3 Opus | $0.015-0.075 | Highest | Long context | -| Claude 3 Sonnet | $0.003-0.015 | High | Good balance | -| Claude 3 Haiku | $0.00025-0.00125 | Good | Fast, cheap | - -### Dynamic Model Selection -```python -def select_model(task_complexity, budget_remaining, daily_limit): - budget_percentage = (daily_limit - budget_remaining) / daily_limit - - if task_complexity == "simple": - return "gpt-3.5-turbo" # Always cheap for simple - elif budget_percentage < 0.5: - return "gpt-4" # Best model when budget healthy - elif budget_percentage < 0.8: - return "gpt-4-turbo" # Balanced - else: - return "gpt-3.5-turbo" # Preserve budget -``` - ---- - -## Optimization Techniques - -### 1. Prompt Engineering for Cost -```python -# Expensive: Long system prompt -system_prompt = """ -You are a helpful assistant that specializes in customer support. -You should always be polite, professional, and helpful. -When answering questions, provide detailed explanations. -Always consider the customer's perspective. -Remember to be empathetic and understanding. -[... 500 more tokens ...] -""" - -# Cheaper: Concise system prompt -system_prompt = """ -Customer support agent. Be helpful, polite, concise. -Resolve issues efficiently. -""" -# Savings: ~400 tokens × 1000 requests = $12/day -``` - -### 2. Context Window Management -```python -def manage_context(messages, max_tokens=4000): - """Keep context within budget by summarizing old messages""" - current_tokens = count_tokens(messages) - - if current_tokens > max_tokens: - # Summarize older messages - old_messages = messages[:-5] # Keep recent - summary = summarize(old_messages) - - return [{"role": "system", "content": f"Previous context: {summary}"}] + messages[-5:] - - return messages -``` - -### 3. Batch Processing -```python -# Expensive: Individual requests -for item in items: - result = llm.complete(f"Process: {item}") - -# Cheaper: Batch when possible -batch_prompt = "Process these items:\n" + "\n".join(items) -results = llm.complete(batch_prompt) -``` - -### 4. Response Length Control -```python -# Add to system prompt -system_prompt += "\nKeep responses under 200 words." - -# Or use max_tokens parameter -response = llm.complete( - prompt, - max_tokens=1024 # Hard limit -) -``` - ---- - -## Runaway Agent Prevention - -### Detection Mechanisms -```python -class RunawayDetector: - def __init__(self): - self.request_times = [] - self.max_requests_per_minute = 50 - self.max_cost_per_minute = 10.00 - - def check(self, cost): - now = time.time() - self.request_times.append((now, cost)) - - # Clean old entries - self.request_times = [ - (t, c) for t, c in self.request_times - if now - t < 60 - ] - - # Check thresholds - requests_per_minute = len(self.request_times) - cost_per_minute = sum(c for _, c in self.request_times) - - if requests_per_minute > self.max_requests_per_minute: - return "RUNAWAY_REQUESTS" - if cost_per_minute > self.max_cost_per_minute: - return "RUNAWAY_COST" - - return "OK" -``` - -### Circuit Breakers -```python -class CostCircuitBreaker: - def __init__(self, threshold, window_seconds=60): - self.threshold = threshold - self.window_seconds = window_seconds - self.costs = [] - self.is_open = False - - def record_cost(self, cost): - now = time.time() - self.costs.append((now, cost)) - self._cleanup() - - total_cost = sum(c for _, c in self.costs) - if total_cost > self.threshold: - self.is_open = True - alert("Circuit breaker opened - costs exceeded threshold") - - def allow_request(self): - if self.is_open: - # Check if we should reset - if time.time() - self.costs[-1][0] > self.window_seconds: - self.is_open = False - self.costs = [] - return True - return False - return True -``` - ---- - -## Dashboard Metrics - -### Essential Cost Metrics - -| Metric | Description | Alert Threshold | -|--------|-------------|-----------------| -| Hourly spend | Cost in last hour | > 2x average | -| Daily spend | Cost today | > 80% budget | -| Cost per task | Average task cost | > expected | -| Token efficiency | Output/input ratio | < 0.3 | -| Cache hit rate | Cached vs new requests | < 50% | -| Model distribution | % by model | Unexpected shifts | - -### Aden Dashboard -Aden provides built-in cost visualization: -- Real-time cost tracking -- Budget gauges with alerts -- Cost by agent/model breakdown -- Historical trends -- Anomaly detection - ---- - -## Best Practices Summary - -### Do's -1. ✅ Set budget limits before deployment -2. ✅ Implement automatic degradation -3. ✅ Monitor costs in real-time -4. ✅ Alert on anomalies -5. ✅ Optimize prompts for token efficiency -6. ✅ Cache common requests -7. ✅ Use appropriate models for task complexity -8. ✅ Review costs regularly - -### Don'ts -1. ❌ Deploy without budget limits -2. ❌ Use GPT-4 for everything -3. ❌ Ignore cost metrics -4. ❌ Allow unlimited retries -5. ❌ Store full context forever -6. ❌ Skip testing cost scenarios -7. ❌ Forget about tool API costs - ---- - -## Conclusion - -AI agent cost management requires: - -1. **Prevention**: Budget limits, degradation policies -2. **Detection**: Real-time tracking, anomaly alerts -3. **Optimization**: Smart model selection, token efficiency -4. **Protection**: Circuit breakers, runaway detection - -Frameworks like Aden with built-in cost controls make this easier, but the principles apply to any agent system. Start with conservative limits and adjust based on real usage patterns. - ---- - -*Last updated: January 2025* diff --git a/docs/articles/ai-agent-observability-monitoring.md b/docs/articles/ai-agent-observability-monitoring.md deleted file mode 100644 index f3c20b53b0..0000000000 --- a/docs/articles/ai-agent-observability-monitoring.md +++ /dev/null @@ -1,423 +0,0 @@ -# AI Agent Observability & Monitoring: The Complete Guide - -*How to know what your AI agents are actually doing* - ---- - -AI agents are autonomous systems that make decisions, call tools, and interact with the world. Without proper observability, they become black boxes. This guide covers everything you need to monitor AI agents effectively. - ---- - -## Why Agent Observability Is Different - -Traditional application monitoring tracks requests and responses. Agent monitoring must track: - -| Traditional Apps | AI Agents | -|------------------|-----------| -| Request/Response | Multi-step reasoning chains | -| Deterministic behavior | Probabilistic decisions | -| Fixed execution paths | Dynamic tool selection | -| Predictable costs | Variable LLM spending | -| Clear errors | Subtle quality degradation | - ---- - -## The Four Pillars of Agent Observability - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Agent Observability Stack │ -│ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ -│ │ Metrics │ │ Logs │ │ Traces │ │ -│ │ (Numbers) │ │ (Events) │ │ (Execution Flow) │ │ -│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌───────────────────────┐ │ -│ │ Quality Evals │ │ -│ │ (Output Assessment) │ │ -│ └───────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 1. Metrics -Quantitative measurements over time: -- Requests per minute -- Success/failure rates -- Latency distributions -- Token usage -- Cost per request -- Tool call frequencies - -### 2. Logs -Discrete events with context: -- Agent decisions -- Tool inputs/outputs -- Error messages -- User interactions -- System events - -### 3. Traces -End-to-end execution flows: -- Full reasoning chains -- Token-by-token generation -- Tool call sequences -- Parent-child relationships -- Cross-agent communication - -### 4. Quality Evals -Output quality assessment: -- Accuracy scoring -- Hallucination detection -- Task completion rates -- User satisfaction -- Regression detection - ---- - -## Key Metrics to Track - -### Performance Metrics -| Metric | Description | Alert Threshold | -|--------|-------------|-----------------| -| `agent.latency.p50` | Median response time | > 5s | -| `agent.latency.p99` | 99th percentile latency | > 30s | -| `agent.throughput` | Requests/second | < baseline * 0.5 | -| `agent.queue.depth` | Pending requests | > 100 | -| `agent.timeout.rate` | Timeout percentage | > 5% | - -### Reliability Metrics -| Metric | Description | Alert Threshold | -|--------|-------------|-----------------| -| `agent.success.rate` | Successful completions | < 95% | -| `agent.error.rate` | Error percentage | > 5% | -| `agent.retry.rate` | Retries needed | > 10% | -| `agent.fallback.rate` | Fallback usage | > 20% | -| `agent.circuit.open` | Circuit breaker status | true | - -### Cost Metrics -| Metric | Description | Alert Threshold | -|--------|-------------|-----------------| -| `agent.cost.total` | Total spend | > budget * 0.9 | -| `agent.cost.per.request` | Cost per request | > $0.50 | -| `agent.tokens.input` | Input tokens used | anomaly detection | -| `agent.tokens.output` | Output tokens used | anomaly detection | -| `agent.model.usage` | Calls by model | unusual patterns | - -### Quality Metrics -| Metric | Description | Alert Threshold | -|--------|-------------|-----------------| -| `agent.quality.score` | Output quality (0-1) | < 0.7 | -| `agent.hallucination.rate` | Detected hallucinations | > 5% | -| `agent.task.completion` | Tasks fully completed | < 80% | -| `agent.user.satisfaction` | User ratings | < 4.0/5.0 | - ---- - -## Logging Best Practices - -### Structured Logging Format -```json -{ - "timestamp": "2025-01-15T10:30:00Z", - "level": "info", - "event": "agent_tool_call", - "agent_id": "agent-123", - "session_id": "session-456", - "trace_id": "trace-789", - "tool": "search_web", - "input": {"query": "latest AI news"}, - "output_tokens": 150, - "latency_ms": 1200, - "success": true -} -``` - -### What to Log - -**Always Log:** -- Agent start/stop -- Tool calls (name, duration, success) -- LLM calls (model, tokens, latency) -- Errors and exceptions -- Human interventions -- Budget events - -**Log Carefully (PII concerns):** -- User inputs (may need redaction) -- Agent outputs (may contain sensitive data) -- Full prompts (can be large) - -**Never Log:** -- API keys -- User credentials -- Full conversation transcripts in production -- Raw model weights - -### Log Levels for Agents - -| Level | Use Case | -|-------|----------| -| DEBUG | Full prompts, token-level details | -| INFO | Tool calls, completions, metrics | -| WARN | Retries, degradation, budget warnings | -| ERROR | Failures, exceptions, circuit breaks | -| FATAL | System crashes, unrecoverable errors | - ---- - -## Distributed Tracing for Agents - -### Why Tracing Matters -Agents involve multiple steps, LLM calls, and tool invocations. Tracing connects them all. - -``` -Trace: "Process customer refund" -├── Span: Agent Initialize (5ms) -├── Span: LLM Planning Call (800ms) -│ └── Attribute: model=gpt-4, tokens=500 -├── Span: Tool: fetch_order (200ms) -│ └── Attribute: order_id=12345 -├── Span: Tool: check_policy (50ms) -├── Span: LLM Decision Call (600ms) -│ └── Attribute: decision=approve -├── Span: Tool: process_refund (300ms) -└── Span: Agent Complete (10ms) - └── Attribute: success=true, cost=$0.08 -``` - -### Key Trace Attributes -- `agent.id`: Unique agent identifier -- `agent.type`: Agent type/role -- `session.id`: User session -- `parent.agent`: For multi-agent systems -- `llm.model`: Model used -- `llm.tokens`: Token counts -- `tool.name`: Tool being called -- `tool.success`: Tool outcome - ---- - -## Dashboard Design - -### Dashboard 1: Operations Overview -``` -┌─────────────────────────────────────────────────────────────┐ -│ Agent Operations │ -├─────────────────┬─────────────────┬─────────────────────────┤ -│ Active Agents │ Requests/Min │ Error Rate │ -│ 42 │ 1,234 │ 0.3% ✓ │ -├─────────────────┴─────────────────┴─────────────────────────┤ -│ │ -│ Request Latency (p50/p99) Success Rate (24h) │ -│ ████████████████░░░░ ██████████████████████ │ -│ 1.2s / 4.5s 99.2% │ -│ │ -├─────────────────────────────────────────────────────────────┤ -│ Top Errors Active Alerts │ -│ • Rate limit exceeded (12) ⚠️ High latency p99 │ -│ • Tool timeout (5) ⚠️ Budget at 85% │ -│ • Validation failed (3) │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Dashboard 2: Cost & Usage -``` -┌─────────────────────────────────────────────────────────────┐ -│ Cost & Usage │ -├─────────────────┬─────────────────┬─────────────────────────┤ -│ Today's Spend │ Budget Used │ Projected Monthly │ -│ $127.50 │ 67% │ $3,825 │ -├─────────────────┴─────────────────┴─────────────────────────┤ -│ │ -│ Cost by Model │ Cost by Agent │ -│ ■ GPT-4: $89 │ ■ Support: $45 │ -│ ■ Claude: $28 │ ■ Research: $52 │ -│ ■ GPT-3.5: $10 │ ■ Writer: $30 │ -│ │ -├─────────────────────────────────────────────────────────────┤ -│ Token Usage Trend (7 days) │ -│ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▂▃▄▅▆ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Dashboard 3: Quality & Reliability -``` -┌─────────────────────────────────────────────────────────────┐ -│ Quality & Reliability │ -├─────────────────┬─────────────────┬─────────────────────────┤ -│ Quality Score │ Task Complete │ User Satisfaction │ -│ 0.92/1.0 │ 94.5% │ 4.6/5.0 │ -├─────────────────┴─────────────────┴─────────────────────────┤ -│ │ -│ Quality Trend (30 days) │ Failure Analysis │ -│ ████████████████████████ │ ■ LLM errors: 2% │ -│ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ │ ■ Tool errors: 1% │ -│ Target: 0.90 │ ■ Timeouts: 0.5% │ -│ │ ■ Logic errors: 0.5% │ -├─────────────────────────────────────────────────────────────┤ -│ Recent Quality Issues │ -│ • Agent-42 hallucination detected (15 min ago) │ -│ • Agent-17 task incomplete (1 hour ago) │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -## Alerting Strategy - -### Critical Alerts (Page immediately) -- Error rate > 10% for 5 minutes -- All agents offline -- Budget exceeded -- Security anomaly detected - -### Warning Alerts (Notify during business hours) -- Error rate > 5% for 15 minutes -- Latency p99 > 30s -- Budget > 90% of limit -- Quality score drops > 10% - -### Informational (Daily digest) -- Token usage trends -- Cost projections -- Quality score changes -- New error types detected - -### Alert Fatigue Prevention -- Use anomaly detection vs fixed thresholds -- Group related alerts -- Implement progressive escalation -- Review and tune alert thresholds monthly - ---- - -## Tool Comparison - -| Tool | Best For | Agent-Specific Features | -|------|----------|------------------------| -| Datadog | Enterprise, full-stack | APM for LLM calls | -| Grafana | Self-hosted, flexibility | Custom dashboards | -| LangSmith | LangChain users | Prompt tracing | -| Weights & Biases | ML teams | Experiment tracking | -| Helicone | LLM-focused | Token analytics | -| Aden | Production agents | Built-in observability | - ---- - -## How Aden Handles Observability - -Aden provides built-in observability without additional setup: - -### Automatic Collection -``` -┌─────────────────────────────────────────────────────────────┐ -│ Aden Observability │ -│ │ -│ ┌───────────────┐ ┌───────────────────────────────┐ │ -│ │ SDK-Wrapped │──────▶│ Event Stream │ │ -│ │ Nodes │ │ • Metrics • Logs • Traces │ │ -│ └───────────────┘ └───────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ Honeycomb Dashboard │ │ -│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ -│ │ │ Metrics │ │ Costs │ │ Quality │ │ Alerts │ │ │ -│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ -│ └───────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### What Aden Tracks Automatically -- Every LLM call (model, tokens, latency, cost) -- Every tool invocation (name, duration, success) -- Agent lifecycle events (start, stop, error) -- Budget consumption in real-time -- Quality metrics via failure tracking -- HITL intervention points - -### Built-in Dashboards -- Real-time agent status -- Cost breakdown by agent/model -- Quality trends over time -- Failure analysis -- Self-improvement metrics - -### No Configuration Required -Unlike external tools, Aden's observability requires no setup: -```python -# Just wrap your node with the SDK -from aden import sdk - -@sdk.node -async def my_agent(input): - # All metrics automatically collected - return await process(input) -``` - ---- - -## Implementation Checklist - -### Phase 1: Basic (Week 1) -- [ ] Structured logging in place -- [ ] Basic metrics: latency, errors, throughput -- [ ] Cost tracking per request -- [ ] Simple dashboard with key metrics - -### Phase 2: Comprehensive (Week 2-3) -- [ ] Distributed tracing implemented -- [ ] Quality evaluation pipeline -- [ ] Alerting rules configured -- [ ] Full dashboards built - -### Phase 3: Advanced (Week 4+) -- [ ] Anomaly detection -- [ ] Automated regression detection -- [ ] Cost optimization insights -- [ ] Self-healing triggers - ---- - -## Common Pitfalls - -### 1. Logging Too Much -**Problem:** Full prompts in production logs -**Solution:** Log hashes or summaries, full content only for debugging - -### 2. Alert Fatigue -**Problem:** Too many non-actionable alerts -**Solution:** Use anomaly detection, tune thresholds, require action plans - -### 3. Missing Context -**Problem:** Can't correlate events across agents -**Solution:** Propagate trace IDs, use correlation IDs - -### 4. Ignoring Quality -**Problem:** Only track operational metrics -**Solution:** Implement quality scoring, track user feedback - -### 5. No Baselines -**Problem:** Don't know what "normal" looks like -**Solution:** Establish baselines before alerting, use relative thresholds - ---- - -## Conclusion - -Effective agent observability requires: - -1. **Metrics**: Know your numbers (latency, errors, cost) -2. **Logs**: Capture events with context -3. **Traces**: Follow execution flows end-to-end -4. **Quality**: Assess output, not just uptime - -Modern agent platforms like Aden provide this built-in. For other frameworks, plan to invest significant effort in observability infrastructure. - -The goal: Never wonder what your agents are doing—always know. - ---- - -*Last updated: January 2025* diff --git a/docs/articles/self-improving-vs-static-agents.md b/docs/articles/self-improving-vs-static-agents.md deleted file mode 100644 index 656328e763..0000000000 --- a/docs/articles/self-improving-vs-static-agents.md +++ /dev/null @@ -1,415 +0,0 @@ -# Self-Improving vs Static Agents: Understanding the Paradigm Shift - -*Why adaptive AI agents are changing how we build intelligent systems* - ---- - -The AI agent landscape is divided between two fundamentally different approaches: **static agents** that execute predefined logic, and **self-improving agents** that evolve based on experience. Understanding this distinction is crucial for choosing the right architecture. - ---- - -## The Core Difference - -### Static Agents -Static agents follow **predefined workflows** that remain constant until a developer manually updates them. They're predictable but require human intervention to improve. - -``` -User Request → Fixed Logic → Response - ↓ - (If failure) - ↓ - Human fixes code - ↓ - Redeploy -``` - -### Self-Improving Agents -Self-improving agents **learn from their experiences**, automatically adjusting their behavior based on successes and failures. - -``` -User Request → Adaptive Logic → Response - ↓ - (If failure) - ↓ - Capture failure data - ↓ - Evolve agent graph - ↓ - Auto-redeploy (improved) -``` - ---- - -## Comparison Table - -| Aspect | Static Agents | Self-Improving Agents | -|--------|---------------|----------------------| -| Behavior change | Manual code updates | Automatic evolution | -| Failure response | Log and alert | Learn and adapt | -| Improvement cycle | Days/weeks | Minutes/hours | -| Human involvement | Required for changes | Optional oversight | -| Predictability | High | Moderate (with guardrails) | -| Long-term maintenance | Higher | Lower | -| Initial complexity | Lower | Higher | - ---- - -## How Static Agents Work - -### Architecture -``` -┌─────────────────────────────────────┐ -│ Static Agent │ -├─────────────────────────────────────┤ -│ ┌─────────────────────────────┐ │ -│ │ Hardcoded Workflow │ │ -│ │ ┌───┐ ┌───┐ ┌───┐ │ │ -│ │ │ A │→│ B │→│ C │ │ │ -│ │ └───┘ └───┘ └───┘ │ │ -│ └─────────────────────────────┘ │ -│ │ -│ • Fixed decision logic │ -│ • Predefined tool usage │ -│ • Static prompts │ -│ • Manual error handling │ -└─────────────────────────────────────┘ -``` - -### Typical Improvement Cycle - -1. **Agent deployed** with initial logic -2. **Failures occur** in production -3. **Developers analyze** logs and errors -4. **Code changes** made manually -5. **Testing** in staging environment -6. **Redeployment** to production -7. **Repeat** for each issue - -**Timeline:** Days to weeks per improvement - -### Examples of Static Agent Frameworks -- LangChain agents -- Basic CrewAI implementations -- Custom ReAct agents -- Simple AutoGen conversations - ---- - -## How Self-Improving Agents Work - -### Architecture -``` -┌─────────────────────────────────────────────────┐ -│ Self-Improving Agent System │ -├─────────────────────────────────────────────────┤ -│ ┌─────────────────────────────────────────┐ │ -│ │ Adaptive Agent Graph │ │ -│ │ ┌───┐ ┌───┐ ┌───┐ │ │ -│ │ │ A │→│ B │→│ C │ ← Can change │ │ -│ │ └───┘ └───┘ └───┘ │ │ -│ └─────────────────────────────────────────┘ │ -│ ↑ │ -│ │ Evolution │ -│ │ │ -│ ┌─────────────────────────────────────────┐ │ -│ │ Coding Agent │ │ -│ │ • Analyzes failures │ │ -│ │ • Generates improvements │ │ -│ │ • Updates agent graph │ │ -│ └─────────────────────────────────────────┘ │ -│ ↑ │ -│ │ │ -│ ┌─────────────────────────────────────────┐ │ -│ │ Failure Capture │ │ -│ │ • Error context │ │ -│ │ • Input/output data │ │ -│ │ • User feedback │ │ -│ └─────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────┘ -``` - -### Typical Improvement Cycle - -1. **Agent deployed** with initial goal-derived logic -2. **Failures captured** automatically with full context -3. **Coding agent analyzes** failure patterns -4. **Graph evolved** with improved logic -5. **Automatic validation** via test cases -6. **Auto-redeployment** (with optional human approval) -7. **Continuous improvement** as more data arrives - -**Timeline:** Minutes to hours per improvement - -### Examples of Self-Improving Systems -- Aden's goal-driven agents -- Custom evolutionary architectures -- Reinforcement learning agents -- Meta-learning systems - ---- - -## When Failures Happen - -### Static Agent Response -```python -# Static agent: failures require manual intervention -try: - result = agent.execute(task) -except AgentError as e: - logger.error(f"Agent failed: {e}") - alert_team(e) # Human must investigate - return fallback_response() - -# Improvement requires: -# 1. Developer reviews logs -# 2. Identifies root cause -# 3. Writes fix -# 4. Tests fix -# 5. Deploys update -``` - -### Self-Improving Agent Response -```python -# Self-improving agent: failures trigger evolution -try: - result = agent.execute(task) -except AgentError as e: - # Automatic failure capture - failure_data = { - "error": e, - "input": task, - "context": agent.get_context(), - "trace": agent.get_execution_trace() - } - - # Coding agent evolves the system - improved_graph = coding_agent.evolve( - current_graph=agent.graph, - failure_data=failure_data - ) - - # Validate and redeploy - if improved_graph.passes_tests(): - agent.update_graph(improved_graph) - - # Retry with improved agent - result = agent.execute(task) -``` - ---- - -## Advantages of Each Approach - -### Static Agents: Advantages - -1. **Predictability** - - Behavior is deterministic - - Easy to test and verify - - No unexpected changes - -2. **Simplicity** - - Easier to understand - - Straightforward debugging - - Lower initial complexity - -3. **Control** - - Full visibility into logic - - Manual approval of all changes - - Compliance-friendly - -4. **Stability** - - No regression from auto-changes - - Consistent performance - - Known failure modes - -### Self-Improving Agents: Advantages - -1. **Adaptability** - - Improves without human intervention - - Handles novel situations - - Evolves with changing needs - -2. **Efficiency** - - Faster improvement cycles - - Reduced developer time - - Lower maintenance burden - -3. **Resilience** - - Self-healing from failures - - Automatic recovery - - Continuous optimization - -4. **Scale** - - Handles more edge cases - - Improves across all instances - - Compounds improvements over time - ---- - -## Challenges of Each Approach - -### Static Agents: Challenges - -- **Slow iteration**: Days/weeks to improve -- **Developer bottleneck**: Changes require engineering time -- **Scaling issues**: More edge cases = more manual work -- **Technical debt**: Accumulated workarounds - -### Self-Improving Agents: Challenges - -- **Unpredictability**: Behavior may change unexpectedly -- **Complexity**: Harder to understand current state -- **Guardrails needed**: Must prevent harmful evolution -- **Debugging**: Tracing why agent behaves certain way - ---- - -## Guardrails for Self-Improving Agents - -Successful self-improving systems need safety mechanisms: - -### 1. Human-in-the-Loop Checkpoints -``` -Evolution proposed → Human review → Approve/Reject -``` - -### 2. Test Case Validation -``` -Improved agent must pass: -- Original test cases -- Regression tests -- New edge case tests -``` - -### 3. Gradual Rollout -``` -Evolution stages: -1. Shadow mode (compare outputs) -2. Canary deployment (small traffic) -3. Full rollout (all traffic) -``` - -### 4. Rollback Capability -``` -If metrics degrade: -- Automatic revert to previous version -- Alert team for investigation -``` - -### 5. Evolution Constraints -``` -Coding agent cannot: -- Remove human checkpoints -- Bypass security measures -- Exceed cost budgets -- Change core objectives -``` - ---- - -## Real-World Scenarios - -### Scenario 1: Customer Support Agent - -**Static Approach:** -- Agent handles known query types -- New query types → escalate to human -- Developer adds new handlers quarterly -- Slow to adapt to trends - -**Self-Improving Approach:** -- Agent learns from successful resolutions -- New patterns automatically incorporated -- Escalation rules evolve based on outcomes -- Continuously adapts to customer needs - -### Scenario 2: Data Processing Pipeline - -**Static Approach:** -- Fixed schema expectations -- New data formats → pipeline breaks -- Manual updates for each change -- High maintenance burden - -**Self-Improving Approach:** -- Learns new data patterns -- Automatically adapts to schema changes -- Self-corrects processing errors -- Lower long-term maintenance - -### Scenario 3: Content Generation - -**Static Approach:** -- Fixed style and structure -- All changes require prompt updates -- No learning from feedback -- Consistent but may become stale - -**Self-Improving Approach:** -- Learns from editor feedback -- Style evolves with brand changes -- Improves quality over time -- Balances consistency with growth - ---- - -## Making the Choice - -### Choose Static Agents When: - -| Situation | Reason | -|-----------|--------| -| Regulatory requirements | Need audit trail of logic | -| Safety-critical systems | Predictability essential | -| Simple, stable workflows | No need for adaptation | -| Small scale | Manual updates manageable | -| High trust requirements | Must explain all decisions | - -### Choose Self-Improving Agents When: - -| Situation | Reason | -|-----------|--------| -| Rapidly changing requirements | Manual updates too slow | -| High volume of edge cases | Can't manually handle all | -| Continuous improvement needed | Competitive advantage | -| Developer time is limited | Automation essential | -| Long-running systems | Evolution provides value | - ---- - -## Implementing Self-Improvement - -### With Aden -Aden provides built-in self-improvement through: - -1. **Goal-driven generation**: Coding agent creates initial system -2. **Failure capture**: Automatic context collection -3. **Evolution engine**: Coding agent improves graph -4. **Validation**: Test cases verify improvements -5. **Deployment**: Automatic with optional approval - -### DIY Approach -Building your own requires: - -1. **Failure logging**: Comprehensive context capture -2. **Analysis system**: Pattern recognition in failures -3. **Code generation**: LLM-based improvement proposals -4. **Testing framework**: Automated validation -5. **Deployment pipeline**: Safe rollout mechanism - ---- - -## Conclusion - -The choice between static and self-improving agents depends on your priorities: - -- **Static agents** offer predictability and control, ideal for stable, regulated environments -- **Self-improving agents** offer adaptability and efficiency, ideal for dynamic, scaling systems - -The future likely belongs to **hybrid approaches**: core logic that's stable and auditable, with adaptive components that evolve safely within guardrails. - -Frameworks like Aden are pioneering this space, making self-improvement accessible while maintaining the safety and oversight that production systems require. - ---- - -*Last updated: January 2025* diff --git a/docs/articles/top-10-ai-agent-frameworks-2025.md b/docs/articles/top-10-ai-agent-frameworks-2025.md deleted file mode 100644 index 5dba7d6af8..0000000000 --- a/docs/articles/top-10-ai-agent-frameworks-2025.md +++ /dev/null @@ -1,326 +0,0 @@ -# Top 10 AI Agent Frameworks in 2025 - -*A comprehensive guide to the leading frameworks for building AI agents* - ---- - -The AI agent landscape has exploded with options for developers. Whether you're building RAG applications, multi-agent systems, or autonomous workflows, choosing the right framework can significantly impact your project's success. - -This guide objectively compares the top 10 AI agent frameworks based on architecture, use cases, and production readiness. - ---- - -## Quick Comparison - -| Framework | Best For | Language | Open Source | Self-Improving | -|-----------|----------|----------|-------------|----------------| -| LangChain | RAG & LLM apps | Python/JS | Yes | No | -| CrewAI | Role-based teams | Python | Yes | No | -| AutoGen | Conversational agents | Python | Yes | No | -| Aden | Self-evolving agents | Python/TS | Yes | Yes | -| PydanticAI | Type-safe workflows | Python | Yes | No | -| Swarm | Simple orchestration | Python | Yes | No | -| CAMEL | Research simulations | Python | Yes | No | -| Letta | Stateful memory | Python | Yes | No | -| Mastra | Full-stack AI | TypeScript | Yes | No | -| Haystack | Search & RAG | Python | Yes | No | - ---- - -## 1. LangChain - -**Category:** Component Library -**Best For:** RAG applications, LLM-powered apps -**Language:** Python, JavaScript - -### Overview -LangChain is one of the most popular frameworks for building LLM applications. It provides a comprehensive set of components for chains, agents, and retrieval-augmented generation. - -### Strengths -- Extensive documentation and community -- Wide integration ecosystem -- Flexible component architecture -- Strong RAG capabilities - -### Limitations -- Can be complex for simple use cases -- Requires manual workflow definition -- No built-in self-improvement mechanisms -- Debugging can be challenging - -### When to Use -Choose LangChain when you need a mature ecosystem with lots of integrations and are building document-centric applications. - ---- - -## 2. CrewAI - -**Category:** Multi-Agent Orchestration -**Best For:** Role-based agent teams -**Language:** Python - -### Overview -CrewAI enables you to create teams of AI agents with defined roles that collaborate to accomplish tasks. It emphasizes simplicity and role-based organization. - -### Strengths -- Intuitive role-based design -- Clean API for team creation -- Good for collaborative workflows -- Active community - -### Limitations -- Predefined collaboration patterns -- Limited adaptation to failures -- Manual workflow definition required -- Scaling can be complex - -### When to Use -Choose CrewAI when you have well-defined roles and want agents to collaborate in predictable patterns. - ---- - -## 3. AutoGen - -**Category:** Conversational Agents -**Best For:** Multi-agent conversations -**Language:** Python - -### Overview -Microsoft's AutoGen framework specializes in conversational AI agents that can engage in complex multi-turn dialogues and collaborate through conversation. - -### Strengths -- Strong conversational capabilities -- Microsoft backing and support -- Good for dialogue-heavy applications -- Flexible agent configuration - -### Limitations -- Conversation-centric (less suited for other patterns) -- Complex setup for non-conversational tasks -- No automatic evolution - -### When to Use -Choose AutoGen when your agents primarily need to communicate through natural language conversations. - ---- - -## 4. Aden - -**Category:** Self-Evolving Agent Framework -**Best For:** Production systems that need to adapt -**Language:** Python SDK, TypeScript backend - -### Overview -Aden takes a fundamentally different approach by using a coding agent to generate agent systems from natural language goals. When agents fail, the framework automatically captures failure data, evolves the agent graph, and redeploys. - -### Strengths -- Goal-driven development (describe outcomes, not workflows) -- Automatic self-improvement from failures -- Built-in observability and cost controls -- Human-in-the-loop support -- Production-ready with monitoring dashboard - -### Limitations -- Newer framework with growing ecosystem -- Requires understanding of goal-driven paradigm -- More suited for complex, evolving systems - -### When to Use -Choose Aden when you need agents that improve over time, want to define goals rather than workflows, or require production-grade observability and cost management. - ---- - -## 5. PydanticAI - -**Category:** Type-Safe Framework -**Best For:** Structured, validated outputs -**Language:** Python - -### Overview -PydanticAI brings type safety and validation to AI agent development, ensuring outputs conform to defined schemas. - -### Strengths -- Strong type validation -- Clean, Pythonic API -- Good for structured outputs -- Reliable data handling - -### Limitations -- Best for known workflow patterns -- Less flexible for dynamic scenarios -- No self-adaptation - -### When to Use -Choose PydanticAI when output structure and validation are critical to your application. - ---- - -## 6. Swarm - -**Category:** Lightweight Orchestration -**Best For:** Simple multi-agent setups -**Language:** Python - -### Overview -OpenAI's Swarm provides a minimal framework for orchestrating multiple agents with simple handoff patterns. - -### Strengths -- Extremely simple API -- Easy to understand and use -- Good for learning -- Minimal overhead - -### Limitations -- Limited features for production -- No built-in monitoring -- Simple handoff patterns only - -### When to Use -Choose Swarm for prototyping or simple multi-agent interactions where complexity isn't needed. - ---- - -## 7. CAMEL - -**Category:** Research Framework -**Best For:** Large-scale agent simulations -**Language:** Python - -### Overview -CAMEL is designed for studying emergent behavior in large-scale multi-agent systems, supporting up to 1M agents. - -### Strengths -- Massive scale support -- Research-oriented features -- Good for studying emergence -- Academic backing - -### Limitations -- Research-focused, not production-ready -- Steep learning curve -- Limited production tooling - -### When to Use -Choose CAMEL for academic research or when studying large-scale agent interactions. - ---- - -## 8. Letta (formerly MemGPT) - -**Category:** Stateful Memory -**Best For:** Long-term memory agents -**Language:** Python - -### Overview -Letta specializes in agents with sophisticated long-term memory, allowing agents to maintain context across extended interactions. - -### Strengths -- Advanced memory management -- Long-term context retention -- Good for personal assistants -- Unique memory architecture - -### Limitations -- Memory-focused (less general purpose) -- Complex memory tuning -- Specific use cases - -### When to Use -Choose Letta when long-term memory and context retention are primary requirements. - ---- - -## 9. Mastra - -**Category:** Full-Stack AI Framework -**Best For:** TypeScript developers -**Language:** TypeScript - -### Overview -Mastra provides a TypeScript-first approach to building AI applications with integrated tooling. - -### Strengths -- TypeScript native -- Full-stack integration -- Modern developer experience -- Good for web applications - -### Limitations -- TypeScript only -- Smaller ecosystem -- Less mature than alternatives - -### When to Use -Choose Mastra when building TypeScript applications and want tight integration with web technologies. - ---- - -## 10. Haystack - -**Category:** Search & RAG -**Best For:** Document processing pipelines -**Language:** Python - -### Overview -Haystack excels at building search and retrieval systems, with strong support for document processing pipelines. - -### Strengths -- Excellent for search applications -- Strong document processing -- Production-tested -- Good pipeline abstractions - -### Limitations -- Search/RAG focused -- Less suited for general agents -- Pipeline-centric design - -### When to Use -Choose Haystack when building search, Q&A, or document processing systems. - ---- - -## Decision Framework - -### Choose Based on Your Primary Need - -| Need | Recommended Framework | -|------|----------------------| -| RAG / Document apps | LangChain, Haystack | -| Role-based teams | CrewAI | -| Conversational agents | AutoGen | -| Self-improving systems | Aden | -| Type-safe outputs | PydanticAI | -| Simple prototypes | Swarm | -| Research simulations | CAMEL | -| Long-term memory | Letta | -| TypeScript apps | Mastra | - -### Choose Based on Production Requirements - -| Requirement | Best Options | -|-------------|--------------| -| Self-healing & adaptation | Aden | -| Mature ecosystem | LangChain | -| Cost management built-in | Aden | -| Simple deployment | Swarm, CrewAI | -| Enterprise support | LangChain, AutoGen | -| Real-time monitoring | Aden | - ---- - -## Conclusion - -The "best" framework depends on your specific needs: - -- **For most RAG applications:** LangChain remains the standard -- **For collaborative agent teams:** CrewAI offers intuitive design -- **For systems that need to evolve:** Aden's self-improving approach is unique -- **For research:** CAMEL provides scale -- **For simplicity:** Swarm is hard to beat - -Consider your production requirements, team expertise, and whether you need agents that can adapt and improve over time when making your decision. - ---- - -*Last updated: January 2025* diff --git a/docs/cleanup-plan.md b/docs/cleanup-plan.md deleted file mode 100644 index c55b967f71..0000000000 --- a/docs/cleanup-plan.md +++ /dev/null @@ -1,161 +0,0 @@ -# Phase 2: FunctionNode Removal + Dead Code Cleanup - -> Ref: [GitHub Issue #4753](https://github.com/adenhq/hive/issues/4753) - -## Context - -`FunctionNode` (`node_type="function"`) breaks three core agent principles: conversation continuity, cumulative tools, and user interruptibility. Phase 1 (soft deprecation warnings) is complete. This plan covers Phase 2 (hard removal) plus cleanup of other dead code discovered during scoping. - -**Total estimated removal: ~5,000+ lines** across production code, tests, docs, and examples. - ---- - -## Part 1: Remove `FunctionNode` class and `"function"` node type - -### 1.1 Core framework - -| File | What to remove/change | -|---|---| -| `core/framework/graph/node.py` | Delete `FunctionNode` class (~L1878-1985). Remove `function` field from `NodeSpec` (~L200). | -| `core/framework/graph/executor.py` | Remove `FunctionNode` import (~L24). Remove `"function"` from `VALID_NODE_TYPES` (~L1473). Remove `node_type == "function"` branch (~L1529-1533). Remove `register_function()` (~L1975-1977). Add migration error for graphs with `node_type="function"`. | -| `core/framework/builder/workflow.py` | Remove `node_type == "function"` validation block (~L258-260). | - -### 1.2 Builder Package Generator - -| File | What to change | -|---|---| -| `core/framework/builder/package_generator.py` | Remove `"function"` from `node_type` description in `add_node` and `update_node`. Remove `node_type == "function"` simulation branch in `test_node`. | - -### 1.3 Examples & demos - -| File | Action | -|---|---| -| `core/examples/manual_agent.py` | Rewrite to use `event_loop` nodes | -| `core/demos/github_outreach_demo.py` | Convert `Sender` node from `function` to `event_loop` | -| `core/examples/mcp_integration_example.py` | Rewrite to use `event_loop` nodes | - -### 1.4 Docs & skills - -| File | Action | -|---|---| -| `docs/developer-guide.md` | Remove `"function"` from node type table (~L495, L856) | -| `docs/developer-guide.md` | Remove `"function"` node type reference (~L613) | -| `core/MCP_SERVER_GUIDE.md` | Audit for `"function"` references | -| `docs/why-conditional-edge-priority.md` | Remove or repurpose (entire doc framed around function nodes) | -| `docs/environment-setup.md` | Remove "function" from node types list (~L216) | -| `docs/i18n/*.md` | Update BUILD diagrams in 7 i18n files (ja, ko, pt, hi, es, ru, zh-CN) removing "Function" | -| `core/framework/runtime/runtime_log_schemas.py` | Remove `"function"` from node_type comment (~L40) | - ---- - -## Part 2: Remove deprecated `LLMNode` + `llm_tool_use` / `llm_generate` - -Already soft-deprecated with `DeprecationWarning`. No template agent uses them. Only `mcp_integration_example.py` references them. - -| File | What to remove/change | -|---|---| -| `core/framework/graph/node.py` | Delete `LLMNode` class (~L660-1689, ~1000 lines). Largest single removal. | -| `core/framework/graph/executor.py` | Remove `LLMNode` import. Remove `"llm_tool_use"`/`"llm_generate"` from `VALID_NODE_TYPES`. Remove `DEPRECATED_NODE_TYPES` dict. Remove their branches in `_get_node_implementation` (~L1507-1523). Update `human_input` branch to use `EventLoopNode` instead of `LLMNode`. Add migration error for deprecated types. | -| `core/framework/builder/package_generator.py` | Remove `llm_tool_use`/`llm_generate` validation warnings and branches | - ---- - -## Part 3: Rewrite tests using `function` nodes as fixtures - -These tests use `node_type="function"` as convenient scaffolding but actually test graph execution features (retries, fan-out, feedback edges, etc.). They all need rewriting. - -| Test file | What it tests | -|---|---| -| `core/tests/test_on_failure_edges.py` | On-failure edge routing (~10 function nodes) | -| `core/tests/test_executor_feedback_edges.py` | Max node visits, feedback loops (~20+ function nodes) | -| `core/tests/test_executor_max_retries.py` | Retry behavior (~7 function nodes) | -| `core/tests/test_fanout.py` | Fan-out/fan-in parallel execution (~20+ function nodes) | -| `core/tests/test_execution_quality.py` | Retry + quality scoring (~8 function nodes) | -| `core/tests/test_conditional_edge_direct_key.py` | Conditional edge evaluation (~8 function nodes) | -| `core/tests/test_event_loop_integration.py` | Mixed node graph test (~2 function nodes) | -| `core/tests/test_runtime_logger.py` | Runtime log schema (~2 references) | -| `tools/tests/tools/test_runtime_logs_tool.py` | Log tool output (~2 references) | - -**Strategy:** Create a `MockNode(NodeProtocol)` test helper that wraps a callable, providing the same convenience as `FunctionNode` but scoped to tests only. Tests swap `node_type="function"` for a neutral `node_type="event_loop"` and register a `MockNode` in the executor's `node_registry`. This minimizes rewrite effort. - ---- - -## Part 4: Items NOT recommended for removal - -| Item | Reason to keep | -|---|---| -| `RouterNode` | Architecturally sound (deterministic routing), just lacks template examples | -| `human_input` node type | Valid HITL pattern, but switch implementation from `LLMNode` to `EventLoopNode` | -| `register_function` in `tool_registry.py` | For **tool** registration — completely different concept from function nodes | - ---- - -## Part 5: Remove the Planner-Worker subsystem (~3,900 lines dead code) - -The entire Planner-Worker-Judge pattern has **zero external consumers**. No template agent, example, demo, or runner references it. It is only consumed by: -- Its own internal files (self-referential imports) -- The builder package generator (exposes tools for it) -- Its own dedicated tests - -### 5.1 Delete these files entirely - -| File | Lines | What | -|---|---|---| -| `core/framework/graph/flexible_executor.py` | 552 | `FlexibleGraphExecutor` — Worker-Judge orchestrator | -| `core/framework/graph/worker_node.py` | 620 | `WorkerNode` — plan step dispatcher | -| `core/framework/graph/plan.py` | 513 | `Plan`, `PlanStep`, `ActionType`, `ActionSpec` data structures | -| `core/framework/graph/judge.py` | 406 | `HybridJudge` — step result evaluator | -| `core/framework/graph/code_sandbox.py` | 413 | `CodeSandbox` — sandboxed code execution | -| `core/tests/test_flexible_executor.py` | 442 | FlexibleGraphExecutor tests | -| `core/tests/test_plan.py` | 592 | Plan data structure tests | -| `core/tests/test_plan_dependency_resolution.py` | 384 | Plan dependency resolution tests | - -### 5.2 Clean up exports - -`core/framework/graph/__init__.py` — Remove all planner-worker exports: `FlexibleGraphExecutor`, `ExecutorConfig`, `WorkerNode`, `StepExecutionResult`, `HybridJudge`, `create_default_judge`, `CodeSandbox`, `safe_eval`, `safe_exec`, `Plan`, `PlanStep`, `ActionType`, `ActionSpec`, and all related symbols. - -### 5.3 Remove MCP tools from builder package generator - -`core/framework/builder/package_generator.py` — Remove these 7 MCP tools: - -| MCP tool | Description | -|---|---| -| `create_plan` | Creates a plan with steps | -| `validate_plan` | Validates plan structure | -| `simulate_plan_execution` | Dry-run simulation | -| `load_exported_plan` | Loads plan from JSON | -| `add_evaluation_rule` | Adds HybridJudge rule | -| `list_evaluation_rules` | Lists evaluation rules | -| `remove_evaluation_rule` | Removes evaluation rule | - -Also remove: -- `from framework.graph.plan import Plan` import (~L39, L3731) -- `_evaluation_rules` global list (~L2528) -- `"evaluation_rules"` from export/session data (~L1859) -- `load_plan_from_json()` helper function (~L3721-3733) - ---- - -## Execution order - -1. **Create `MockNode` test helper** — unblocks all test rewrites -2. **Rewrite tests** using function nodes as fixtures (Part 3) -3. **Remove `FunctionNode` class + all references** (Part 1) -4. **Remove `LLMNode` class + deprecated types** (Part 2) -5. **Delete Planner-Worker subsystem files** (Part 5.1) -6. **Clean up `__init__.py` exports** (Part 5.2) -7. **Remove MCP tools** for plans/evaluation from builder package generator (Part 5.3) -8. **Update examples/demos/docs/skills** (Parts 1.3, 1.4) -9. **Run full test suite** to verify - ---- - -## Verification - -1. `pytest core/tests/` — all tests pass -2. `pytest tools/tests/` — runtime log tests pass -3. Load any template agent JSON — no errors -4. Attempt to load a graph with `node_type="function"` — clear `RuntimeError` with migration guidance -5. Attempt to load a graph with `node_type="llm_tool_use"` — clear `RuntimeError` with migration guidance -6. Builder package generator: `add_node` with `node_type="function"` — rejected with helpful message -7. Plan/evaluation MCP tools no longer appear in tool list diff --git a/docs/credential-identity-plan.md b/docs/credential-identity-plan.md deleted file mode 100644 index 349571e8a2..0000000000 --- a/docs/credential-identity-plan.md +++ /dev/null @@ -1,981 +0,0 @@ -# Credential Identity & Multi-Account Foundation (Issue #4755) - -## Context - -Agents are identity-blind. When `gmail_read_email` runs, neither the LLM nor the tool -knows whose inbox it's operating on. One `ADEN_API_KEY` can back N accounts of the same -provider (e.g., 10 Gmail accounts), but today the system can only surface one — the last -one synced silently overwrites all others. - -This plan traces the **5-tuple relationship** (Agent Definition → Agent Instance → -Agent Tool → Auth Provider → Auth User Identity) through every layer of the stack, -identifies exactly where things break, and prescribes targeted fixes. - -### Motivating Scenarios - -**Scenario A — Executive Assistant Agent**: A company deploys an agent that manages -calendars for 5 executives. Each executive has connected their Google account through -Aden. The agent's job is to check each person's availability and schedule meetings. -Today: the agent can only see ONE person's calendar (whichever synced last). The other -4 accounts are silently lost in the index collision. The agent schedules meetings on -the wrong person's calendar with no indication anything is wrong. - -**Scenario B — Multi-Channel Support Agent**: A support team agent is connected to -3 Slack workspaces (Engineering, Sales, Support), a shared Gmail inbox, and a personal -Gmail for the team lead. Today: the agent sees one Slack workspace, one Gmail. It -cannot tell which Slack workspace it's posting to or whose Gmail it's reading. It -might reply to a customer email from the team lead's personal inbox. - -**Scenario C — Compliance & Audit**: An enterprise client requires audit logs showing -which account was accessed, when, and by which agent. Today: the system logs -`credentials.get("google")` — no record of which of the 10 Google accounts was used. -Impossible to audit. - -**Scenario D — Single-Account Agent (backward compat)**: A simple agent uses one -Gmail account and one Slack bot. Nothing should change. `credentials.get("google")` -returns the same token it always did. Zero migration, zero configuration changes. - ---- - -## The 5-Tuple Model - -Every credential interaction involves five entities. Understanding how they relate -(and where the relationships break) is the key to the fix. - -``` -Agent Definition ──→ Agent Instance ──→ Agent Tool ──→ Auth Provider ──→ Auth User Identity - "I need Gmail" "Here's your "Give me a "Here's one "Whose token - Gmail tool" token" token" is this?" - ← MISSING -``` - -### 1. Agent Definition (what tools are needed) - -**Files**: `exports/{name}/agent.py`, `nodes/__init__.py`, `mcp_servers.json` - -An exported agent declares `NodeSpec.tools = ["gmail_read_email", "gmail_send_email"]`. -The `mcp_servers.json` points to the tools MCP server. The agent definition has NO -credential awareness — it names tools, not credentials. This is intentional: the same -agent definition can run against different credential sets in different environments -(dev vs. prod, tenant A vs. tenant B). - -**Business logic**: Agent definitions are portable templates. A "Gmail Triage" agent -built by one team can be deployed to 50 different customers, each with their own -Google accounts. The agent definition never hard-codes credential IDs. - -**Status**: Fine. No changes needed. - -### 2. Agent Instance (runtime wiring) - -**Files**: `runner.py`, `tool_registry.py`, `mcp_client.py` - -`AgentRunner.__init__()` does three things in sequence: -1. `validate_agent_credentials(graph.nodes)` — checks presence + health -2. `ToolRegistry.load_mcp_config()` → `MCPClient` spawns subprocess -3. `_setup()` → `create_agent_runtime()` with discovered tools - -The `ToolRegistry` bridges parent ↔ MCP subprocess: -- `CONTEXT_PARAMS = {"workspace_id", "agent_id", "session_id", "data_dir"}` — stripped - from LLM schema, injected at call time via `make_mcp_executor` closure -- `set_session_context()` — set once at startup -- `set_execution_context()` — per-execution via `contextvars` - -The MCP subprocess inherits `os.environ` at spawn time via -`merged_env = {**os.environ, **(config.env or {})}` in `mcp_client.py:157`. - -**Business logic**: The agent instance is where "portable template" meets "specific -deployment." An instance knows which Aden API key to use, which workspace it belongs -to, which tools are available. The `CONTEXT_PARAMS` mechanism is how the framework -passes deployment-specific context into tools without the LLM knowing or caring. -This is the natural extension point for `account` routing in the future. - -**Scenario**: Two customers both deploy the same "Email Triage" agent. Customer A -has 2 Google accounts; Customer B has 5. Each customer's `AgentRunner` validates -against their own Aden key, discovers different sets of credentials, and wires them -into the same agent graph. The agent definition is identical. - -**Status**: Works for single-account. The `CONTEXT_PARAMS` pattern is the right -mechanism for future multi-account routing (adding `account` param). - -### 3. Agent Tool (credential consumption) - -**Files**: `tools/src/aden_tools/tools/*/`, `tools/mcp_server.py` - -Every tool follows the same pattern: -```python -def register_gmail_tools(mcp, credentials=None): - def _get_token(): - if credentials is not None: - return credentials.get("google") # ← single token, identity unknown - return os.getenv("GOOGLE_ACCESS_TOKEN") - - @mcp.tool() - def gmail_read_email(message_id: str): - token = _get_token() - ... -``` - -The `credentials` object is `CredentialStoreAdapter`, created once at MCP server startup -via `CredentialStoreAdapter.default()`. All tool closures capture this single shared -instance. - -**Business logic**: Tools are the consumer endpoint — they need a valid access token -to call external APIs. They don't care about Aden, sync, or storage. They just need -`_get_token()` to return the right token. Today, "right" is undefined because there's -no way to say "the token for alice@company.com, not bob@company.com." - -**Where it breaks — Scenario A revisited**: The executive assistant agent calls -`gmail_read_email()` intending to read Alice's inbox. `_get_token()` returns -`credentials.get("google")` which resolves to... Bob's token (he synced last). -The agent reads Bob's emails, thinks they're Alice's, and schedules meetings -accordingly. No error is raised. No indication anything is wrong. The agent is -confidently operating on the wrong person's data. - -**Where it breaks — Scenario B revisited**: The support agent calls -`slack_post_message(channel="support-tickets")`. It uses a Slack token from -the Engineering workspace (last synced). The message goes to a channel that -doesn't exist in Engineering, returns an error, and the agent retries in a loop -with no understanding of why it's failing. - -### 4. Auth Provider (credential storage & resolution) - -**Files**: `store.py`, `aden/storage.py`, `aden/provider.py`, `aden/client.py` - -Resolution chain: -``` -credentials.get("google") -→ CredentialStoreAdapter.get("google") -→ CredentialStore.get("google") -→ AdenCachedStorage.load("google") -→ _provider_index.get("google") → "google_def456" (last write wins) -→ _load_by_id("google_def456") -→ Returns ONE CredentialObject -``` - -**The index collision bug** (`storage.py:303`): -```python -def _index_provider(self, credential): - provider_name = integration_type_key.value.get_secret_value() - self._provider_index[provider_name] = credential.id # ← OVERWRITES -``` - -**Business logic**: The storage layer is responsible for mapping human-readable -provider names ("google") to internal hash-based credential IDs ("google_abc123"). -This mapping is essential because Aden generates unique hash IDs per connected account, -but tools reference providers by name. The `_provider_index` is this mapping. - -**Why it's a `dict[str, str]` today**: The original design assumed 1:1 between -provider name and credential. "One Google account per API key." This was valid -for simple deployments but breaks fundamentally when an Aden API key backs multiple -accounts of the same provider. - -**The collision mechanics**: When `sync_all()` runs, it iterates over all active -integrations from Aden. For a user with 3 Gmail accounts: - -1. Sync `google_abc123` (alice@co.com) → `_provider_index["google"] = "google_abc123"` -2. Sync `google_def456` (bob@co.com) → `_provider_index["google"] = "google_def456"` ← Alice lost -3. Sync `google_ghi789` (carol@co.com) → `_provider_index["google"] = "google_ghi789"` ← Bob lost - -All three `.enc` files exist on disk. Only Carol's is reachable by name. Alice's and -Bob's tokens are orphaned — encrypted, on disk, but invisible to the resolution chain. - -**Why the disk layer is fine**: `EncryptedFileStorage` uses the hash ID as filename: -`google_abc123.enc`, `google_def456.enc`. No collision. The problem is purely in the -in-memory index that maps names to IDs. - -### 5. Auth User Identity (THE MISSING PIECE) - -**Files**: `models.py` (no identity model), `aden/provider.py` (metadata discarded), -`health_check.py` (identity parsed then discarded), `validation.py` (details ignored) - -**Business logic**: Identity answers "whose account is this?" Every external service -provides identity data in its API responses — Gmail returns `emailAddress`, GitHub -returns `login`, Slack returns `team` + `user`. This data already flows through the -system during health checks and Aden syncs. It's parsed, briefly held in local -variables, and then discarded. No model captures it. No property exposes it. No -downstream consumer reads it. - -Identity data exists at two sources but is discarded: - -| Source | Data Available | What Happens | -|--------|---------------|--------------| -| Aden `metadata.email` | Email of connected account | `_aden_response_to_credential()` ignores `metadata` dict | -| Gmail health check | `emailAddress` field | `OAuthBearerHealthChecker.check()` returns `valid=True`, discards response body | -| GitHub health check | `login` username | Parsed to `details["username"]`, validation ignores `details` | -| Slack health check | `team`, `user` | Parsed to `details`, validation ignores `details` | -| Discord health check | `username`, `id` | Parsed to `details`, validation ignores `details` | -| Calendar health check | Primary calendar `id` = email | `OAuthBearerHealthChecker.check()` discards response body | - -**The waste**: Every agent startup already makes these health check API calls. The -identity data is RIGHT THERE in the response body. We parse it for validation logic, -then throw it away. Zero additional API calls needed — we just need to keep what we -already have. - -**What identity enables downstream**: -- LLM knows whose inbox it's reading (system prompt awareness) -- Tools can route to specific accounts (future `account` parameter) -- Audit logs can record which identity was accessed -- Users can see which accounts are connected in TUI/dashboard -- Agents can reason about cross-account operations ("forward from alice to bob") - ---- - -## What Changes — Layer by Layer - -### Step 1: `CredentialIdentity` model on `CredentialObject` - -**File**: `core/framework/credentials/models.py` - -**Business logic**: Every credential needs a structured way to answer "who does this -belong to?" Different providers express identity differently: - -| Provider | Primary Identity | Secondary Identity | -|----------|-----------------|-------------------| -| Google (Gmail, Calendar, Drive) | Email address | — | -| Slack | Workspace name | Bot username | -| GitHub | Username (login) | — | -| Discord | Username | Account ID | -| HubSpot | Portal ID | — | -| Microsoft 365 | Email address | Tenant ID | - -The `CredentialIdentity` model normalizes these into four universal fields: -`email`, `username`, `workspace`, `account_id`. The `label` property picks the -best human-readable identifier for display (email preferred, then username, etc.). - -**Why a computed property, not a stored field**: Identity is derived from -`_identity_*` keys that already exist in the credential's key vault. Storing it -as a separate field would create a sync problem (what if keys update but the field -doesn't?). A computed property always reflects current state. - -**Scenarios this enables**: - -- **Display**: `cred.identity.label` → `"alice@company.com"` (for system prompts, TUI, logs) -- **Comparison**: `cred.identity.email == "alice@company.com"` (for account routing) -- **Serialization**: `cred.identity.to_dict()` → `{"email": "alice@company.com"}` (for MCP tool responses) -- **Existence check**: `cred.identity.is_known` → `True` (skip accounts with no identity) -- **Provider type**: `cred.provider_type` → `"google"` (from `_integration_type` key) - -**Key design decision**: `set_identity(**fields)` persists as `_identity_*` keys using -the existing `set_key()` method. This means identity survives serialization/deserialization -through `EncryptedFileStorage` without any schema migration. Old credentials without -identity keys simply return `CredentialIdentity()` with all `None` fields and -`label == "unknown"`. - -```python -class CredentialIdentity(BaseModel): - email: str | None = None - username: str | None = None - workspace: str | None = None - account_id: str | None = None - - @property - def label(self) -> str: - return self.email or self.username or self.workspace or self.account_id or "unknown" - - @property - def is_known(self) -> bool: - return bool(self.email or self.username or self.workspace or self.account_id) - - def to_dict(self) -> dict[str, str]: - return {k: v for k, v in self.model_dump().items() if v is not None} -``` - -On `CredentialObject`: - -```python -@property -def identity(self) -> CredentialIdentity: - fields = {} - for key_name, key_obj in self.keys.items(): - if key_name.startswith("_identity_"): - field = key_name[len("_identity_"):] - fields[field] = key_obj.value.get_secret_value() - return CredentialIdentity(**{k: v for k, v in fields.items() - if k in CredentialIdentity.model_fields}) - -@property -def provider_type(self) -> str | None: - key = self.keys.get("_integration_type") - return key.value.get_secret_value() if key else None - -def set_identity(self, **fields: str) -> None: - for field_name, value in fields.items(): - if value: - self.set_key(f"_identity_{field_name}", value) -``` - ---- - -### Step 2: Fix storage multi-account index - -**File**: `core/framework/credentials/aden/storage.py` - -**Business logic**: The core bug. When a user connects multiple accounts of the same -provider type through Aden, all but the last one becomes unreachable. This affects -every multi-account deployment silently — no error, no warning, just missing accounts. - -**`_provider_index`**: `dict[str, str]` → `dict[str, list[str]]` - -**Before (broken)**: -``` -sync google_abc123 (alice) → index["google"] = "google_abc123" -sync google_def456 (bob) → index["google"] = "google_def456" ← alice lost -load("google") → returns bob's token -``` - -**After (fixed)**: -``` -sync google_abc123 (alice) → index["google"] = ["google_abc123"] -sync google_def456 (bob) → index["google"] = ["google_abc123", "google_def456"] -load("google") → returns alice's token (first = backward compat) -load_all_for_provider("google") → returns [alice, bob] -``` - -**Backward compatibility contract**: Every existing tool calls `credentials.get("google")` -and expects a single token string back. This MUST continue to work. `load("google")` -returns the first credential in the list — same behavior as before for single-account -deployments, deterministic (first-synced-first-served) for multi-account. - -**Scenarios**: - -- **Single account** (most common today): `index["google"] = ["google_abc123"]`. - `load("google")` returns the only entry. Identical behavior to before. - -- **Two accounts, same provider**: `index["google"] = ["google_abc123", "google_def456"]`. - `load("google")` returns first. `load_all_for_provider("google")` returns both. - Existing tools see no change; new APIs can enumerate. - -- **Mixed providers**: `index["google"] = ["google_abc123"], index["slack"] = ["slack_xyz"]`. - Each provider resolves independently. - -- **Credential removed from Aden**: On next `sync_all()`, `rebuild_provider_index()` - rebuilds from disk. The removed credential's `.enc` file is gone, so it drops from - the index naturally. - -- **`exists()` check**: Validation calls `exists("google")` to check if credentials - are available before running health checks. Must return `True` if ANY Google account - exists, not just the last-synced one. - -```python -# _index_provider — append, don't overwrite -def _index_provider(self, credential): - ... - if provider_name not in self._provider_index: - self._provider_index[provider_name] = [] - if credential.id not in self._provider_index[provider_name]: - self._provider_index[provider_name].append(credential.id) - -# load — first match (backward compat) -def load(self, credential_id): - resolved_ids = self._provider_index.get(credential_id) - if resolved_ids: - for rid in resolved_ids: - if rid != credential_id: - result = self._load_by_id(rid) - if result is not None: - return result - return self._load_by_id(credential_id) - -# NEW: enumerate all accounts -def load_all_for_provider(self, provider_name: str) -> list[CredentialObject]: - results = [] - for cid in self._provider_index.get(provider_name, []): - cred = self._load_by_id(cid) - if cred: - results.append(cred) - return results -``` - ---- - -### Step 3: Preserve Aden metadata as identity - -**File**: `core/framework/credentials/aden/provider.py` - -**Business logic**: When a user connects a Google account through Aden's OAuth flow, -the Aden server stores metadata about the connected account — most importantly, the -email address. This metadata comes back in every API response as -`metadata: {"email": "alice@company.com"}`. Today, this metadata is present in -`AdenCredentialResponse.metadata` (the `from_dict()` parser already handles it) but -is never written into the `CredentialObject`'s key vault. It's silently dropped. - -**Why Aden metadata is the primary identity source**: Aden captures identity at the -moment of OAuth authorization — the user explicitly grants access, and the Aden server -records who they are. This is more authoritative than health checks because: -1. It's captured at consent time, not at validation time -2. It works even if the health check endpoint is down -3. It's available immediately on first sync, before any health check runs - -**When metadata arrives**: Two code paths create/update credentials from Aden responses: - -1. **`_aden_response_to_credential()`** — first-time sync. The credential doesn't - exist locally yet. We're building it from scratch. Metadata should be written as - `_identity_*` keys in the initial key dict. - -2. **`_update_credential_from_aden()`** — token refresh. The credential already exists. - The access token is updated. Metadata should be written/overwritten as `_identity_*` - keys on the existing credential object. - -**Scenario — first sync**: User connects `alice@company.com` through Aden. Aden -returns `{access_token: "...", metadata: {email: "alice@company.com"}}`. The -credential is created with `_identity_email = "alice@company.com"`. Later, -`cred.identity.email` returns `"alice@company.com"`. - -**Scenario — token refresh**: Alice's token expires. Aden refreshes it and returns -updated metadata. `_update_credential_from_aden()` updates the access token AND -refreshes `_identity_email`. If Alice changed her email (e.g., name change), the -identity stays current. - -**Scenario — no metadata**: Some Aden integrations may not return metadata (e.g., -a simple API key integration). The loop `for meta_key, meta_value in (metadata or {}).items()` -safely does nothing. The credential has no `_identity_*` keys, and `cred.identity` -returns `CredentialIdentity()` with `label == "unknown"`. - -```python -# In _aden_response_to_credential, after building keys dict: -for meta_key, meta_value in (aden_response.metadata or {}).items(): - if meta_value and isinstance(meta_value, str): - keys[f"_identity_{meta_key}"] = CredentialKey( - name=f"_identity_{meta_key}", - value=SecretStr(meta_value), - ) - -# In _update_credential_from_aden, after updating access_token: -for meta_key, meta_value in (aden_response.metadata or {}).items(): - if meta_value and isinstance(meta_value, str): - credential.keys[f"_identity_{meta_key}"] = CredentialKey( - name=f"_identity_{meta_key}", - value=SecretStr(meta_value), - ) -``` - ---- - -### Step 4: Extract identity from health checks - -**File**: `tools/src/aden_tools/credentials/health_check.py` - -**Business logic**: Health checks are the second identity source. Every agent startup -runs `validate_agent_credentials()` which calls provider-specific health check -endpoints. These endpoints return identity data as a side effect of validation: - -| Health Check Endpoint | What It Returns | Identity We Extract | -|----------------------|----------------|-------------------| -| Gmail: `GET /users/me/profile` | `{emailAddress, messagesTotal, ...}` | `email = emailAddress` | -| Calendar: `GET /users/me/calendarList` | `{items: [{id, primary, ...}]}` | `email = primary calendar id` | -| Slack: `POST auth.test` | `{ok, team, user, bot_id, ...}` | `workspace = team, username = user` | -| GitHub: `GET /user` | `{login, id, name, ...}` | `username = login` | -| Discord: `GET /users/@me` | `{username, id, ...}` | `username = username` | - -**Why health checks matter as an identity source**: - -1. **Fallback when Aden metadata is missing**: Not all Aden integrations return - metadata. The health check always hits the actual service, so identity is always - available on success. - -2. **Ground truth verification**: Aden metadata is captured at OAuth time. If the - user's email changed since then, the health check returns the CURRENT identity. - -3. **Non-Aden credentials**: When credentials are configured via environment - variables (no Aden), health checks are the ONLY identity source. A dev sets - `GOOGLE_ACCESS_TOKEN` manually — the health check reveals whose token it is. - -4. **Zero additional cost**: The health check API call is already happening. We - just need to parse the response body that's currently discarded after the - status code check. - -**Design — `_extract_identity()` hook**: The base `OAuthBearerHealthChecker` gets -a new virtual method `_extract_identity(data: dict) -> dict[str, str]` that subclasses -override. The `check()` method calls it when the response is 200 OK: - -```python -class OAuthBearerHealthChecker: - def _extract_identity(self, data: dict) -> dict[str, str]: - """Override to extract identity fields from successful response.""" - return {} - - def check(self, access_token: str) -> HealthCheckResult: - ... - if response.status_code == 200: - identity = {} - try: - data = response.json() - identity = self._extract_identity(data) - except Exception: - pass # Identity extraction is best-effort - return HealthCheckResult( - valid=True, - message=f"{self.service_name} credentials valid", - details={"identity": identity} if identity else {}, - ) -``` - -**Why `details["identity"]`**: The existing `HealthCheckResult` has a `details: dict` -field that's used ad-hoc by different checkers. By putting identity under a standardized -`"identity"` key, Step 5 can generically extract it without knowing which checker -ran. Existing `details` fields (`username`, `team`, `bot_id`) continue to exist -alongside — no breaking changes. - -**Standalone checkers** (Slack, GitHub, Discord) don't extend `OAuthBearerHealthChecker`. -They already parse identity data into their `details` dict. For these, we simply add -an `"identity"` key with the structured fields alongside existing keys. - -**Scenario — Gmail health check enriches a credential without Aden metadata**: A dev -sets `GOOGLE_ACCESS_TOKEN` as an env var. The credential has no `_identity_*` keys. -On startup, the Gmail health check calls `/users/me/profile`, gets -`{emailAddress: "dev@gmail.com"}`, returns `details={"identity": {"email": "dev@gmail.com"}}`. -Step 5 persists this. Now `cred.identity.email` works even without Aden. - -**Scenario — health check fails**: Token is expired or revoked. Response is 401. -No identity extracted (identity extraction only runs on 200). The health check -returns `valid=False`. Step 5 skips persistence. The credential's existing identity -(if any, from Aden metadata) remains unchanged. - -**Scenario — identity extraction throws**: The response body is malformed or missing -expected fields. The `try/except` in `check()` catches it. Health check still returns -`valid=True` (the token worked). Identity is just not extracted. Best-effort, never -blocks validation. - ---- - -### Step 5: Persist identity during validation - -**File**: `core/framework/credentials/validation.py` - -**Business logic**: Steps 3 and 4 produce identity data. Step 5 is the bridge that -takes identity from health check results and persists it to the credential store. -This runs during `validate_agent_credentials()`, which is called at every agent startup. - -**Why persist during validation**: Validation is the natural lifecycle hook because: -1. It runs on every agent startup (guaranteed execution) -2. It already has access to the credential store -3. It already runs health checks (identity is available in the result) -4. It runs BEFORE the agent executes (identity is available for system prompt injection) - -**Flow**: -``` -Agent startup -→ validate_agent_credentials() - → for each credential: - → check_credential_health(token) → HealthCheckResult - → if result.valid AND result.details["identity"] exists: - → cred_obj = store.get_credential(cred_id) - → cred_obj.set_identity(**identity_data) - → store.save_credential(cred_obj) ← persisted to disk -``` - -**Scenario — identity from health check augments Aden metadata**: Aden provides -`metadata.email = "alice@company.com"` (stored as `_identity_email` in Step 3). -The Slack health check returns `identity: {workspace: "Acme Corp", username: "hive-bot"}`. -Step 5 adds `_identity_workspace` and `_identity_username` to the Slack credential. -Now both credentials have rich identity data from their respective sources. - -**Scenario — identity update on restart**: Between agent runs, the GitHub user -renamed from `old-username` to `new-username`. On next startup, the health check -returns `identity: {username: "new-username"}`. Step 5 calls `set_identity(username="new-username")`, -which overwrites `_identity_username`. The credential now reflects the current identity. - -**Scenario — multiple accounts of same provider**: With the index fix (Step 2), -`validate_agent_credentials()` iterates over all credentials. Each Google account -gets its own health check. Each health check returns a different `emailAddress`. -Each identity is persisted to the correct `CredentialObject`. Account A gets -`_identity_email = "alice@co.com"`, Account B gets `_identity_email = "bob@co.com"`. - -**Error handling**: Identity persistence is best-effort. If `get_credential()` fails -or `save_credential()` fails, the exception is caught and swallowed. The agent still -starts. The credential still works. It just won't have identity data for that account. -This is acceptable because identity is informational, not functional. - -```python -if result.valid: - identity_data = result.details.get("identity") - if identity_data and isinstance(identity_data, dict): - try: - cred_obj = store.get_credential(cred_id, refresh_if_needed=False) - if cred_obj: - cred_obj.set_identity(**identity_data) - store.save_credential(cred_obj) - except Exception: - pass # Identity persistence is best-effort -``` - ---- - -### Step 6: Account listing & identity APIs - -**Files**: `core/framework/credentials/store.py`, `tools/src/aden_tools/credentials/store_adapter.py` - -**Business logic**: Steps 1-5 populate identity data. Step 6 exposes it through -clean APIs. Two layers need new methods: - -1. **`CredentialStore`** (framework layer) — knows about `CredentialObject` and storage -2. **`CredentialStoreAdapter`** (tool boundary) — wraps the store with `CredentialSpec`-aware - APIs, sits in the MCP subprocess, consumed by tools - -**Why two layers**: The store is a framework concept (core/). The adapter is a tools -concept (tools/). Tools never import from core directly. The adapter bridges the gap, -translating between credential IDs and spec names, handling the "is this credential -configured and available?" logic. - -**APIs added to `CredentialStore`**: - -- `list_accounts(provider_name)` — returns all accounts for a provider type with - their identities. Delegates to `storage.load_all_for_provider()` (Step 2). Returns - a list of dicts, not raw `CredentialObject`s, to avoid leaking secrets upstream. - -- `get_credential_by_identity(provider_name, label)` — finds a specific account by - matching `cred.identity.label` against the provided label. This is the resolution - mechanism for future multi-account routing: "give me the token for alice@co.com." - -**APIs added to `CredentialStoreAdapter`**: - -- `get_identity(name)` — returns the identity dict for a named credential spec. - Used by tools that want to know whose token they're using for logging/display. - -- `list_accounts(provider_name)` — delegates to store. Used by the `get_account_info` - MCP tool (Step 8). - -- `get_all_account_info()` — iterates over all configured credential specs, collects - all accounts across all providers. Used to build the system prompt (Step 7). - Deduplicates by provider name to avoid listing the same provider's accounts twice - when multiple specs map to the same provider. - -- `get_by_identity(provider_name, label)` — resolves a specific account's token by - identity label. Used by future multi-account routing (Step 9). Returns a raw token - string, not a `CredentialObject`. - -**Scenario — system prompt building**: At agent startup, the runner calls -`adapter.get_all_account_info()`. The adapter iterates over specs: -`{"gmail": CredentialSpec(credential_id="google"), "gcal": CredentialSpec(credential_id="google"), "slack": CredentialSpec(...)}`. -It deduplicates by provider: `google` and `slack`. For `google`, `list_accounts("google")` -returns 2 accounts. For `slack`, 1 account. Result: 3 account entries for the system prompt. - -**Scenario — identity-based routing (future)**: The LLM calls -`gmail_read_email(account="alice@co.com")`. The tool calls -`credentials.get_by_identity("google", "alice@co.com")`. The adapter delegates to -`store.get_credential_by_identity("google", "alice@co.com")` which scans all Google -credentials, finds the one where `identity.label == "alice@co.com"`, and returns -its access token. The right inbox is read. - -```python -# CredentialStore -def list_accounts(self, provider_name: str) -> list[dict[str, Any]]: - if hasattr(self._storage, 'load_all_for_provider'): - creds = self._storage.load_all_for_provider(provider_name) - else: - cred = self.get_credential(provider_name) - creds = [cred] if cred else [] - return [ - {"credential_id": c.id, "provider": provider_name, - "identity": c.identity.to_dict(), "label": c.identity.label} - for c in creds - ] - -def get_credential_by_identity(self, provider_name: str, label: str) -> CredentialObject | None: - if hasattr(self._storage, 'load_all_for_provider'): - for cred in self._storage.load_all_for_provider(provider_name): - if cred.identity.label == label: - return cred - return None -``` - -```python -# CredentialStoreAdapter -def get_all_account_info(self) -> list[dict[str, Any]]: - accounts = [] - seen: set[str] = set() - for name, spec in self._specs.items(): - provider = spec.credential_id or name - if provider in seen or not self.is_available(name): - continue - seen.add(provider) - accounts.extend(self._store.list_accounts(provider)) - return accounts - -def get_by_identity(self, provider_name: str, label: str) -> str | None: - cred = self._store.get_credential_by_identity(provider_name, label) - return cred.get_default_key() if cred else None -``` - ---- - -### Step 7: Surface identity to LLM via system prompt - -**Files**: `prompt_composer.py`, `executor.py`, `event_loop_node.py`, `node.py`, `runner.py` - -**Business logic**: The LLM needs to know what accounts are connected so it can: - -1. **Communicate clearly to the user**: "I checked alice@company.com's inbox and - found 3 unread messages" vs. "I checked the inbox and found 3 unread messages" - -2. **Disambiguate operations**: When asked "check my emails," the LLM can respond - "You have 2 Google accounts connected: alice@company.com and bob@company.com. - Which would you like me to check?" (requires Step 9 routing, but awareness comes first) - -3. **Prevent hallucination**: Without account info, the LLM might invent account - names or assume capabilities it doesn't have. With the accounts prompt, it knows - exactly what's available. - -4. **Cross-account reasoning**: "Forward the email from alice's inbox to bob's inbox" - requires knowing both accounts exist and which is which. - -**Where it sits in the three-layer prompt**: -``` -Layer 1 — Identity: "You are a thorough email management agent." - Accounts: "Connected accounts: - - google: alice@company.com (email: alice@company.com) - - google: bob@company.com (email: bob@company.com) - - slack: Acme Corp (workspace: Acme Corp, username: hive-bot)" -Layer 2 — Narrative: "We've triaged 15 emails so far..." -Layer 3 — Focus: "Your current task: categorize remaining unread emails" -``` - -Accounts sit between identity (static personality) and narrative (dynamic state) -because connected accounts are semi-static — they don't change during a session but -are deployment-specific (different from the agent definition). - -**Injection path through the framework**: -``` -AgentRunner._setup() - → CredentialStoreAdapter.get_all_account_info() - → build_accounts_prompt(accounts) ← new function in prompt_composer.py - → GraphExecutor(accounts_prompt=...) ← new init param - → NodeContext(accounts_prompt=...) ← new field - → compose_system_prompt(..., accounts_prompt=...) ← new param -``` - -**Why it flows through `NodeContext`**: For the first node in a graph (or an isolated -`EventLoopNode`), the system prompt is built in `EventLoopNode.execute()`, not through -the continuous transition path. `NodeContext.accounts_prompt` carries the data to -both paths: - -- **Continuous transition**: `compose_system_prompt()` in the executor uses - `self.accounts_prompt` directly -- **First node / isolated node**: `EventLoopNode.execute()` reads `ctx.accounts_prompt` - and appends it to the system prompt - -**Scenario — no credentials**: An agent with no external integrations (pure LLM -reasoning, no tools). `get_all_account_info()` returns `[]`. `build_accounts_prompt([])` -returns `""`. The accounts block is omitted from the system prompt. Zero impact. - -**Scenario — single account**: One Google account. System prompt shows -`"Connected accounts:\n- google: alice@company.com (email: alice@company.com)"`. -The LLM knows who it's operating as. - -**Scenario — unknown identity**: A credential exists but has no `_identity_*` keys -(maybe Aden didn't provide metadata and health checks haven't run yet). `identity.label` -returns `"unknown"`. The prompt shows `"- google: unknown"`. Better than nothing — -the LLM knows Google is connected, just not whose account. - -```python -def build_accounts_prompt(accounts: list[dict[str, Any]]) -> str: - if not accounts: - return "" - lines = ["Connected accounts:"] - for acct in accounts: - provider = acct.get("provider", "unknown") - label = acct.get("label", "unknown") - identity = acct.get("identity", {}) - detail_parts = [f"{k}: {v}" for k, v in identity.items() if v] - detail = f" ({', '.join(detail_parts)})" if detail_parts else "" - lines.append(f"- {provider}: {label}{detail}") - return "\n".join(lines) -``` - ---- - -### Step 8: `get_account_info` MCP tool - -**New directory**: `tools/src/aden_tools/tools/account_info_tool/` - -**Business logic**: Step 7 gives the LLM passive awareness (system prompt). Step 8 -gives the LLM active introspection — it can call `get_account_info()` to query -connected accounts at runtime, even mid-conversation. - -**Why both passive and active**: The system prompt provides context at conversation -start. But in long-running agents with many tools, the system prompt may get -compacted (truncated during context management). The MCP tool ensures the LLM can -always re-discover account info even after compaction. - -**Use cases**: - -- **User asks "what accounts are connected?"**: LLM calls `get_account_info()`, - formats the response for the user. - -- **LLM needs to decide which account to use**: Before sending an email, the LLM - calls `get_account_info(provider="google")` to see which Gmail accounts are - available, then asks the user which one to send from. - -- **Dynamic account discovery**: In a long-running session, accounts might be - added/revoked (Aden dashboard). The tool provides current state vs. the stale - system prompt. - -- **Debugging/transparency**: The user can ask "which Slack workspace are you - connected to?" and get a precise answer. - -**API design**: - -```python -@mcp.tool() -def get_account_info(provider: str = "") -> dict: - """List connected accounts and their identities. - - Call with no arguments to see all connected accounts. - Call with provider="google" to filter by provider type. - - Returns account IDs, provider types, and identity labels - (email, username, workspace) for each connected account. - """ - if credentials is None: - return {"accounts": [], "message": "No credential store configured"} - if provider: - accounts = credentials.list_accounts(provider) - else: - accounts = credentials.get_all_account_info() - return {"accounts": accounts, "count": len(accounts)} -``` - -**Response example**: -```json -{ - "accounts": [ - {"credential_id": "google_abc123", "provider": "google", - "identity": {"email": "alice@company.com"}, "label": "alice@company.com"}, - {"credential_id": "google_def456", "provider": "google", - "identity": {"email": "bob@company.com"}, "label": "bob@company.com"}, - {"credential_id": "slack_xyz", "provider": "slack", - "identity": {"workspace": "Acme Corp", "username": "hive-bot"}, - "label": "Acme Corp"} - ], - "count": 3 -} -``` - -Register in `tools/src/aden_tools/tools/__init__.py` alongside existing tools. - ---- - -### Step 9: Multi-account routing extension point (design only, no code) - -**Business logic**: Steps 1-8 build the foundation. Step 9 designs (but does not -implement) the per-tool-call account selection mechanism. This is the endgame: -when the LLM calls `gmail_read_email(account="alice@co.com")`, the right token -is used. - -**Why design-only in this PR**: Multi-account routing requires changes to every -tool's `_get_token()` function and introduces the `account` parameter across all -tool signatures. This is a significant surface area change that should be a -separate PR with its own testing. The foundation from Steps 1-8 makes it a -straightforward addition. - -**How it will work — the full flow**: - -1. **LLM discovers accounts**: Via system prompt (Step 7) or `get_account_info` tool - (Step 8), the LLM knows `alice@company.com` and `bob@company.com` are connected. - -2. **User says "check alice's inbox"**: The LLM calls - `gmail_read_email(account="alice@company.com")`. - -3. **Tool resolves account**: `_get_token("alice@company.com")` calls - `credentials.get_by_identity("google", "alice@company.com")`. - -4. **Store resolves credential**: `get_credential_by_identity("google", "alice@company.com")` - scans all Google credentials, finds the one where `identity.label == "alice@company.com"`, - returns its access token. - -5. **API call with correct token**: The tool uses Alice's token to call the Gmail API. - The right inbox is read. - -**Pinned single-account agents**: For agents that should ALWAYS use a specific account -(e.g., a shared support inbox), the `account` parameter becomes a `CONTEXT_PARAM` in -`ToolRegistry`. It's stripped from the LLM schema (the LLM can't override it) and -auto-injected at call time from `NodeSpec` or `GraphSpec` configuration. This follows -the exact same pattern as `data_dir` — proven, concurrency-safe, framework-native. - -**Why `CredentialIdentity.label` is the stable routing key**: -- It's human-readable (email addresses, usernames) -- It's deterministic (computed from `_identity_*` keys) -- It matches what the LLM sees in the system prompt -- It survives credential refresh (identity doesn't change when tokens rotate) -- It's unique within a provider (two Google accounts always have different emails) - ---- - -## How This Works with Exported/Template Agents - -### Agent definition (no changes) - -Exported agents in `exports/` declare tools via `NodeSpec.tools` and MCP servers via -`mcp_servers.json`. They don't know about credentials — this is by design. Credential -specs (`CredentialSpec.tools`) provide the external mapping from tool name to credential. - -**Scenario — same agent, different deployments**: The "Email Triage" agent template -is used by 3 customers. Customer A has 1 Gmail account. Customer B has 5. Customer C -has 3 Gmail and 2 Outlook. The agent definition is identical for all three. Only -the Aden API key (and thus the available credentials) differs. - -### Agent instance (accounts_prompt injection) - -When `AgentRunner.load()` instantiates an agent: -1. `validate_agent_credentials()` runs — syncs Aden, checks presence/health -2. Identity is persisted during validation (Step 5) -3. `_setup()` collects `accounts_prompt` via `CredentialStoreAdapter.get_all_account_info()` -4. Passes to `GraphExecutor(accounts_prompt=...)` → `compose_system_prompt()` - -The agent definition doesn't need to change. Identity flows through the existing -runtime wiring. - -### MCP subprocess (independent adapter) - -The MCP subprocess creates its own `CredentialStoreAdapter.default()` at startup. -This triggers an independent `sync_all()` from Aden. With the index fix (Step 2), -all accounts are preserved. The adapter's new methods (`list_accounts()`, -`get_all_account_info()`, `get_by_identity()`) are available to tools in the subprocess. - -**Why independent sync is correct**: The MCP subprocess runs in a separate process -with its own memory space. It cannot share the parent's `CredentialStore`. Both -processes sync from the same Aden server (same API key), so they see the same -credentials. The disk-level `EncryptedFileStorage` handles concurrent access safely -(each read is atomic file read, writes use temp+rename). - -### ToolRegistry bridge (future routing) - -When multi-account routing is implemented (Step 9), the `account` parameter will be -added to `CONTEXT_PARAMS`. `ToolRegistry._convert_mcp_tool_to_framework_tool()` will -strip it from LLM schema (line 467). `make_mcp_executor()` will inject it at call time -(line 421). This follows the exact same pattern as `data_dir`. - ---- - -## Files Modified (Summary) - -| # | File | Changes | -|---|------|---------| -| 1 | `core/framework/credentials/models.py` | `CredentialIdentity`, `identity` property, `set_identity()`, `provider_type` | -| 2 | `core/framework/credentials/aden/storage.py` | `_provider_index: dict[str, list[str]]`, `load_all_for_provider()`, fix `exists()`, `rebuild_provider_index()` | -| 3 | `core/framework/credentials/aden/provider.py` | Persist `metadata` as `_identity_*` keys in both `_aden_response_to_credential` and `_update_credential_from_aden` | -| 4 | `tools/src/aden_tools/credentials/health_check.py` | `_extract_identity()` hook on `OAuthBearerHealthChecker`, overrides per checker, `identity` key in standalone checker `details` | -| 5 | `core/framework/credentials/validation.py` | Persist identity from health check `details["identity"]` via `set_identity()` | -| 6 | `core/framework/credentials/store.py` | `list_accounts()`, `get_credential_by_identity()` | -| 7 | `tools/src/aden_tools/credentials/store_adapter.py` | `get_identity()`, `list_accounts()`, `get_all_account_info()`, `get_by_identity()` | -| 8 | `core/framework/graph/prompt_composer.py` | `build_accounts_prompt()`, `accounts_prompt` param on `compose_system_prompt()` | -| 9 | `core/framework/graph/node.py` | `accounts_prompt: str = ""` on `NodeContext` | -| 10 | `core/framework/graph/executor.py` | `accounts_prompt` init param, pass to `compose_system_prompt()` and `_build_context()` | -| 11 | `core/framework/graph/event_loop_node.py` | Append `accounts_prompt` for first node system prompt | -| 12 | `core/framework/runner/runner.py` | Collect accounts info in `_setup()`, pass to executor | -| 13 | `tools/src/aden_tools/tools/account_info_tool/` | New `get_account_info` MCP tool | -| 14 | `tools/src/aden_tools/tools/__init__.py` | Register account info tool | - ---- - -## Verification - -1. **Multi-index**: Sync 2 Google accounts → both in `_provider_index["google"]` (not overwritten) -2. **Identity model**: `cred.identity.email` returns email, `cred.identity.label` returns best label -3. **Health check identity**: `GoogleGmailHealthChecker.check(token)` → `result.details["identity"]["email"]` -4. **Persistence**: After validation, credential on disk has `_identity_email` key -5. **Account listing**: `adapter.list_accounts("google")` → 2 accounts with distinct identities -6. **System prompt**: `compose_system_prompt(accounts_prompt=...)` includes "Connected accounts" -7. **MCP tool**: `get_account_info(provider="google")` returns both accounts with labels -8. **Backward compat**: `credentials.get("google")` still returns single token string -9. **Existing tests**: `PYTHONPATH=core:tools/src python -m pytest tools/tests/ -x -q -k "credential"` diff --git a/docs/credential-store-design.md b/docs/credential-store-design.md deleted file mode 100644 index cd442dc598..0000000000 --- a/docs/credential-store-design.md +++ /dev/null @@ -1,1765 +0,0 @@ -# Production-Ready Credential Store Design - -## Overview - -This document describes the design for a production-ready credential store for the Hive agent framework. The system provides: - -- **Key-vault structure**: Credentials as objects with multiple keys (e.g., `cred1.api_key`, `cred2.access_token`) -- **Template-based usage**: Tools specify `{{cred.key}}` patterns for injection into headers/params -- **Bipartisan model**: Store only stores values; tools define how they're used -- **Provider system**: Extensible providers (OAuth2, static, custom) for credential lifecycle management -- **OSS extensibility**: Interfaces for users to implement custom providers -- **External vault integration**: HashiCorp Vault adapter for enterprise deployments - ---- - -## Table of Contents - -1. [Architecture Overview](#architecture-overview) -2. [Core Data Models](#core-data-models) -3. [Template Resolution System](#template-resolution-system) -4. [Provider Interface](#provider-interface) -5. [Storage Backends](#storage-backends) -6. [Main Credential Store](#main-credential-store) -7. [OAuth2 Module](#oauth2-module) -8. [HashiCorp Vault Integration](#hashicorp-vault-integration) -9. [Backward Compatibility](#backward-compatibility) -10. [Usage Examples](#usage-examples) -11. [Implementation Plan](#implementation-plan) -12. [Security Considerations](#security-considerations) - ---- - -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ CredentialStore │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Template Resolver │ │ -│ │ {{cred.key}} → actual value resolution │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │ -│ │ CredentialObject│ │ CredentialObject│ │CredentialObject│ │ -│ │ brave_search │ │ github_oauth │ │ salesforce │ │ -│ │ ┌─────────────┐│ │ ┌─────────────┐ │ │ ┌────────────┐ │ │ -│ │ │api_key: xxx ││ │ │access_token │ │ │ │access_token│ │ │ -│ │ └─────────────┘│ │ │refresh_token│ │ │ │instance_url│ │ │ -│ └─────────────────┘ │ │expires_at │ │ │ └────────────┘ │ │ -│ │ └─────────────┘ │ └────────────────┘ │ -│ └─────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Providers │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ -│ │ │StaticProvider│ │OAuth2Provider│ │ CustomProvider │ │ │ -│ │ │ (no refresh) │ │(auto-refresh)│ │ (user-defined) │ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Storage Backends │ │ -│ │ ┌────────────────┐ ┌────────────────┐ ┌───────────────┐ │ │ -│ │ │EncryptedFile │ │ EnvVar │ │HashiCorpVault │ │ │ -│ │ │ (Fernet AES) │ │ (read-only) │ │ (external) │ │ │ -│ │ └────────────────┘ └────────────────┘ └───────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Core Data Models - -**Location**: `core/framework/credentials/models.py` - -### CredentialType - -```python -from enum import Enum - -class CredentialType(str, Enum): - """Types of credentials the store can manage.""" - API_KEY = "api_key" # Simple API key (e.g., Brave Search) - OAUTH2 = "oauth2" # OAuth2 with refresh support - BASIC_AUTH = "basic_auth" # Username/password pair - BEARER_TOKEN = "bearer_token" # JWT or bearer token - CUSTOM = "custom" # User-defined credential type -``` - -### CredentialKey - -```python -from datetime import datetime -from typing import Any, Dict, Optional -from pydantic import BaseModel, SecretStr, Field - -class CredentialKey(BaseModel): - """ - A single key within a credential object. - - Example: 'api_key' within a 'brave_search' credential - """ - name: str - value: SecretStr # Prevents accidental logging - expires_at: Optional[datetime] = None - metadata: Dict[str, Any] = Field(default_factory=dict) - - @property - def is_expired(self) -> bool: - """Check if this key has expired.""" - if self.expires_at is None: - return False - return datetime.utcnow() >= self.expires_at - - def get_secret_value(self) -> str: - """Get the actual secret value (use sparingly).""" - return self.value.get_secret_value() -``` - -### CredentialObject - -```python -class CredentialObject(BaseModel): - """ - A credential object containing one or more keys. - - This is the key-vault structure where each credential can have - multiple keys (e.g., access_token, refresh_token, expires_at). - - Example: - CredentialObject( - id="github_oauth", - credential_type=CredentialType.OAUTH2, - keys={ - "access_token": CredentialKey(name="access_token", value="ghp_xxx"), - "refresh_token": CredentialKey(name="refresh_token", value="ghr_xxx"), - }, - provider_id="oauth2" - ) - """ - id: str = Field(description="Unique identifier (e.g., 'brave_search', 'github_oauth')") - credential_type: CredentialType = CredentialType.API_KEY - keys: Dict[str, CredentialKey] = Field(default_factory=dict) - - # Lifecycle management - provider_id: Optional[str] = Field( - default=None, - description="ID of provider responsible for lifecycle (e.g., 'oauth2')" - ) - last_refreshed: Optional[datetime] = None - auto_refresh: bool = False - - # Usage tracking - last_used: Optional[datetime] = None - use_count: int = 0 - - # Metadata - description: str = "" - tags: list[str] = Field(default_factory=list) - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) - - def get_key(self, key_name: str) -> Optional[str]: - """Get a specific key's value.""" - key = self.keys.get(key_name) - if key is None: - return None - return key.get_secret_value() - - def set_key( - self, - key_name: str, - value: str, - expires_at: Optional[datetime] = None - ) -> None: - """Set or update a key.""" - self.keys[key_name] = CredentialKey( - name=key_name, - value=SecretStr(value), - expires_at=expires_at - ) - self.updated_at = datetime.utcnow() - - @property - def needs_refresh(self) -> bool: - """Check if any key is expired or near expiration.""" - for key in self.keys.values(): - if key.is_expired: - return True - return False - - def record_usage(self) -> None: - """Record that this credential was used.""" - self.last_used = datetime.utcnow() - self.use_count += 1 -``` - -### CredentialUsageSpec - -```python -class CredentialUsageSpec(BaseModel): - """ - Specification for how a tool uses credentials. - - This implements the "bipartisan" model where the credential store - just stores values, and tools define how those values are used. - - Example: - CredentialUsageSpec( - credential_id="brave_search", - required_keys=["api_key"], - headers={"X-Subscription-Token": "{{api_key}}"} - ) - """ - credential_id: str = Field(description="ID of credential to use") - required_keys: list[str] = Field( - default_factory=list, - description="Keys that must be present (e.g., ['api_key'])" - ) - - # Injection templates (bipartisan model) - headers: Dict[str, str] = Field( - default_factory=dict, - description="Header templates (e.g., {'Authorization': 'Bearer {{access_token}}'})" - ) - query_params: Dict[str, str] = Field( - default_factory=dict, - description="Query param templates (e.g., {'api_key': '{{api_key}}'})" - ) - body_fields: Dict[str, str] = Field( - default_factory=dict, - description="Request body field templates" - ) - - required: bool = True - description: str = "" - help_url: str = "" -``` - ---- - -## Template Resolution System - -**Location**: `core/framework/credentials/template.py` - -The template resolver handles `{{cred.key}}` patterns, enabling the bipartisan model where tools specify how credentials are used. - -### Template Syntax - -| Pattern | Meaning | Example | -|---------|---------|---------| -| `{{credential_id.key_name}}` | Access specific key | `{{github_oauth.access_token}}` | -| `{{credential_id}}` | Access default key | `{{brave_search}}` → `api_key` value | - -### TemplateResolver Class - -```python -import re -from typing import Optional - -class TemplateResolver: - """ - Resolves credential templates like {{cred.key}} into actual values. - - Examples: - "Bearer {{github_oauth.access_token}}" -> "Bearer ghp_xxx" - "X-API-Key: {{brave_search.api_key}}" -> "X-API-Key: BSAKxxx" - """ - - TEMPLATE_PATTERN = re.compile(r"\{\{([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_]+))?\}\}") - - def __init__(self, credential_store: "CredentialStore"): - self._store = credential_store - - def resolve(self, template: str, fail_on_missing: bool = True) -> str: - """ - Resolve all credential references in a template string. - - Args: - template: String containing {{cred.key}} patterns - fail_on_missing: If True, raise error on missing credentials - - Returns: - Template with all references replaced with actual values - - Raises: - CredentialNotFoundError: If credential doesn't exist - CredentialKeyNotFoundError: If key doesn't exist in credential - """ - def replace_match(match: re.Match) -> str: - cred_id = match.group(1) - key_name = match.group(2) # May be None - - credential = self._store.get_credential(cred_id) - if credential is None: - if fail_on_missing: - raise CredentialNotFoundError(f"Credential '{cred_id}' not found") - return match.group(0) - - # Get specific key or default - if key_name: - value = credential.get_key(key_name) - if value is None: - raise CredentialKeyNotFoundError( - f"Key '{key_name}' not found in credential '{cred_id}'" - ) - else: - # Default: use 'value', 'api_key', or first key - value = self._get_default_key(credential) - - return value - - return self.TEMPLATE_PATTERN.sub(replace_match, template) - - def resolve_headers( - self, - header_templates: Dict[str, str], - fail_on_missing: bool = True - ) -> Dict[str, str]: - """Resolve templates in a headers dictionary.""" - return { - key: self.resolve(value, fail_on_missing) - for key, value in header_templates.items() - } - - def has_templates(self, text: str) -> bool: - """Check if text contains any credential templates.""" - return bool(self.TEMPLATE_PATTERN.search(text)) - - def extract_references(self, text: str) -> list[tuple[str, Optional[str]]]: - """ - Extract all credential references from text. - - Returns list of (credential_id, key_name) tuples. - """ - return [ - (match.group(1), match.group(2)) - for match in self.TEMPLATE_PATTERN.finditer(text) - ] - - def _get_default_key(self, credential: CredentialObject) -> str: - """Get the default key value for a credential.""" - for key_name in ["value", "api_key", "access_token"]: - if key_name in credential.keys: - return credential.get_key(key_name) - - if credential.keys: - first_key = next(iter(credential.keys)) - return credential.get_key(first_key) - - raise CredentialKeyNotFoundError( - f"Credential '{credential.id}' has no keys" - ) - - -class CredentialNotFoundError(Exception): - """Raised when a referenced credential doesn't exist.""" - pass - - -class CredentialKeyNotFoundError(Exception): - """Raised when a referenced key doesn't exist in a credential.""" - pass -``` - ---- - -## Provider Interface - -**Location**: `core/framework/credentials/provider.py` - -Providers handle credential lifecycle operations (refresh, validate, revoke). - -### CredentialProvider ABC - -```python -from abc import ABC, abstractmethod -from datetime import datetime, timedelta -from typing import List -import logging - -logger = logging.getLogger(__name__) - - -class CredentialProvider(ABC): - """ - Abstract base class for credential providers. - - Providers handle credential lifecycle operations: - - Refresh: Obtain new tokens when expired - - Validate: Check if credentials are still working - - Revoke: Invalidate credentials when no longer needed - - OSS users can implement custom providers by subclassing this. - """ - - @property - @abstractmethod - def provider_id(self) -> str: - """Unique identifier for this provider (e.g., 'oauth2', 'static').""" - pass - - @property - @abstractmethod - def supported_types(self) -> List[CredentialType]: - """Credential types this provider can manage.""" - pass - - @abstractmethod - def refresh(self, credential: CredentialObject) -> CredentialObject: - """ - Refresh the credential (e.g., use refresh_token to get new access_token). - - Args: - credential: The credential to refresh - - Returns: - Updated credential with new values - - Raises: - CredentialRefreshError: If refresh fails - """ - pass - - @abstractmethod - def validate(self, credential: CredentialObject) -> bool: - """ - Validate that a credential is still working. - - Args: - credential: The credential to validate - - Returns: - True if credential is valid, False otherwise - """ - pass - - def should_refresh(self, credential: CredentialObject) -> bool: - """ - Determine if a credential should be refreshed. - - Default: refresh if any key is expired or within 5 minutes of expiry. - Override for custom logic. - """ - buffer = timedelta(minutes=5) - now = datetime.utcnow() - - for key in credential.keys.values(): - if key.expires_at is not None: - if key.expires_at <= now + buffer: - return True - return False - - def revoke(self, credential: CredentialObject) -> bool: - """ - Revoke a credential (optional operation). - - Returns: - True if revocation succeeded, False otherwise - """ - logger.warning(f"Provider {self.provider_id} does not support revocation") - return False - - -class CredentialRefreshError(Exception): - """Raised when credential refresh fails.""" - pass -``` - -### StaticProvider - -```python -class StaticProvider(CredentialProvider): - """ - Provider for static credentials that never need refresh. - - Use for simple API keys that don't expire. - """ - - @property - def provider_id(self) -> str: - return "static" - - @property - def supported_types(self) -> List[CredentialType]: - return [CredentialType.API_KEY, CredentialType.CUSTOM] - - def refresh(self, credential: CredentialObject) -> CredentialObject: - # Static credentials don't refresh - return credential - - def validate(self, credential: CredentialObject) -> bool: - # Static credentials are always "valid" from our perspective - return len(credential.keys) > 0 - - def should_refresh(self, credential: CredentialObject) -> bool: - return False # Never refresh -``` - ---- - -## Storage Backends - -**Location**: `core/framework/credentials/storage.py` - -### CredentialStorage ABC - -```python -from abc import ABC, abstractmethod -from typing import List, Optional - -class CredentialStorage(ABC): - """ - Abstract storage backend for credentials. - - Implementations: - - EncryptedFileStorage: Local encrypted JSON files (default) - - EnvVarStorage: Environment variables (backward compatibility) - - HashiCorpVaultStorage: HashiCorp Vault integration - """ - - @abstractmethod - def save(self, credential: CredentialObject) -> None: - """Save a credential to storage.""" - pass - - @abstractmethod - def load(self, credential_id: str) -> Optional[CredentialObject]: - """Load a credential from storage.""" - pass - - @abstractmethod - def delete(self, credential_id: str) -> bool: - """Delete a credential. Returns True if existed.""" - pass - - @abstractmethod - def list_all(self) -> List[str]: - """List all credential IDs.""" - pass - - @abstractmethod - def exists(self, credential_id: str) -> bool: - """Check if a credential exists.""" - pass -``` - -### EncryptedFileStorage - -```python -from pathlib import Path -import json - -class EncryptedFileStorage(CredentialStorage): - """ - Encrypted file-based credential storage. - - Uses Fernet symmetric encryption (AES-128-CBC + HMAC). - Stores each credential as a separate encrypted JSON file. - - Directory structure: - {base_path}/ - credentials/ - {credential_id}.enc # Encrypted credential JSON - metadata/ - index.json # Index of all credentials - - Encryption key is read from HIVE_CREDENTIAL_KEY environment variable. - """ - - def __init__( - self, - base_path: str | Path, - encryption_key: Optional[bytes] = None, - key_env_var: str = "HIVE_CREDENTIAL_KEY" - ): - """ - Initialize encrypted storage. - - Args: - base_path: Directory for credential files - encryption_key: 32-byte Fernet key. If None, reads from env var. - key_env_var: Environment variable containing encryption key - """ - from cryptography.fernet import Fernet - import os - - self.base_path = Path(base_path) - self._ensure_dirs() - - # Get or generate encryption key - if encryption_key: - self._key = encryption_key - else: - key_str = os.environ.get(key_env_var) - if key_str: - self._key = key_str.encode() - else: - # Generate new key (user must persist this!) - self._key = Fernet.generate_key() - logger.warning( - f"Generated new encryption key. " - f"Set {key_env_var}={self._key.decode()} to persist credentials." - ) - - self._fernet = Fernet(self._key) - - def save(self, credential: CredentialObject) -> None: - """Encrypt and save credential.""" - data = self._serialize_credential(credential) - json_bytes = json.dumps(data).encode() - encrypted = self._fernet.encrypt(json_bytes) - - cred_path = self._cred_path(credential.id) - with open(cred_path, "wb") as f: - f.write(encrypted) - - self._update_index(credential.id, "save") - - def load(self, credential_id: str) -> Optional[CredentialObject]: - """Load and decrypt credential.""" - cred_path = self._cred_path(credential_id) - if not cred_path.exists(): - return None - - with open(cred_path, "rb") as f: - encrypted = f.read() - - try: - json_bytes = self._fernet.decrypt(encrypted) - data = json.loads(json_bytes.decode()) - except Exception as e: - raise CredentialDecryptionError( - f"Failed to decrypt credential '{credential_id}': {e}" - ) - - return self._deserialize_credential(data) - - def delete(self, credential_id: str) -> bool: - """Delete a credential file.""" - cred_path = self._cred_path(credential_id) - if cred_path.exists(): - cred_path.unlink() - self._update_index(credential_id, "delete") - return True - return False - - def list_all(self) -> List[str]: - """List all credential IDs.""" - index_path = self.base_path / "metadata" / "index.json" - if not index_path.exists(): - return [] - with open(index_path) as f: - index = json.load(f) - return list(index.get("credentials", {}).keys()) - - def exists(self, credential_id: str) -> bool: - return self._cred_path(credential_id).exists() - - # ... helper methods ... - - -class CredentialDecryptionError(Exception): - """Raised when credential decryption fails.""" - pass -``` - -### EnvVarStorage (Backward Compatibility) - -```python -class EnvVarStorage(CredentialStorage): - """ - Environment variable-based storage for backward compatibility. - - Maps credential IDs to environment variable patterns. - Single-key credentials only. Read-only (cannot save). - - Supports hot-reload from .env files. - """ - - def __init__( - self, - env_mapping: Optional[Dict[str, str]] = None, - dotenv_path: Optional[Path] = None - ): - """ - Args: - env_mapping: Map of credential_id -> env_var_name - e.g., {"brave_search": "BRAVE_SEARCH_API_KEY"} - dotenv_path: Path to .env file for hot-reload - """ - self._env_mapping = env_mapping or {} - self._dotenv_path = dotenv_path or Path.cwd() / ".env" - - def save(self, credential: CredentialObject) -> None: - """Cannot save to environment variables at runtime.""" - raise NotImplementedError( - "EnvVarStorage is read-only. Set environment variables externally." - ) - - def load(self, credential_id: str) -> Optional[CredentialObject]: - """Load credential from environment variable.""" - import os - from dotenv import dotenv_values - - env_var = self._env_mapping.get(credential_id) - if not env_var: - env_var = f"{credential_id.upper()}_API_KEY" - - # Check os.environ first, then .env file - value = os.environ.get(env_var) - if not value and self._dotenv_path.exists(): - values = dotenv_values(self._dotenv_path) - value = values.get(env_var) - - if not value: - return None - - return CredentialObject( - id=credential_id, - credential_type=CredentialType.API_KEY, - keys={"api_key": CredentialKey(name="api_key", value=SecretStr(value))} - ) - - # ... other methods ... -``` - ---- - -## Main Credential Store - -**Location**: `core/framework/credentials/store.py` - -```python -import threading -from typing import Dict, List, Optional -from datetime import datetime - - -class CredentialStore: - """ - Main credential store orchestrating storage, providers, and template resolution. - - Features: - - Multi-backend storage (file, env, vault) - - Provider-based lifecycle management (refresh, validate) - - Template resolution for {{cred.key}} patterns - - Caching with TTL for performance - - Thread-safe operations - - Usage: - store = CredentialStore( - storage=EncryptedFileStorage("~/.hive/credentials"), - providers=[OAuth2Provider(), StaticProvider()] - ) - - # Get a credential - cred = store.get_credential("github_oauth") - - # Resolve templates in headers - headers = store.resolve_headers({ - "Authorization": "Bearer {{github_oauth.access_token}}" - }) - - # Register a tool's credential requirements - store.register_usage(CredentialUsageSpec( - credential_id="brave_search", - required_keys=["api_key"], - headers={"X-Subscription-Token": "{{brave_search.api_key}}"} - )) - """ - - def __init__( - self, - storage: Optional[CredentialStorage] = None, - providers: Optional[List[CredentialProvider]] = None, - cache_ttl_seconds: int = 300, - auto_refresh: bool = True, - ): - """ - Initialize the credential store. - - Args: - storage: Storage backend. Defaults to EnvVarStorage. - providers: List of credential providers. Defaults to [StaticProvider()]. - cache_ttl_seconds: How long to cache credentials in memory. - auto_refresh: Whether to auto-refresh expired credentials. - """ - self._storage = storage or EnvVarStorage() - self._providers: Dict[str, CredentialProvider] = {} - self._usage_specs: Dict[str, CredentialUsageSpec] = {} - - # Cache - self._cache: Dict[str, tuple[CredentialObject, datetime]] = {} - self._cache_ttl = cache_ttl_seconds - self._lock = threading.RLock() - - self._auto_refresh = auto_refresh - - # Register providers - for provider in (providers or [StaticProvider()]): - self.register_provider(provider) - - # Template resolver - self._resolver = TemplateResolver(self) - - def register_provider(self, provider: CredentialProvider) -> None: - """Register a credential provider.""" - self._providers[provider.provider_id] = provider - - def register_usage(self, spec: CredentialUsageSpec) -> None: - """Register how a tool uses credentials.""" - self._usage_specs[spec.credential_id] = spec - - # --- Credential Access --- - - def get_credential( - self, - credential_id: str, - refresh_if_needed: bool = True - ) -> Optional[CredentialObject]: - """ - Get a credential by ID. - - Args: - credential_id: The credential identifier - refresh_if_needed: If True, refresh expired credentials - - Returns: - CredentialObject or None if not found - """ - with self._lock: - # Check cache - cached = self._get_from_cache(credential_id) - if cached is not None: - if refresh_if_needed and self._should_refresh(cached): - return self._refresh_credential(cached) - return cached - - # Load from storage - credential = self._storage.load(credential_id) - if credential is None: - return None - - # Refresh if needed - if refresh_if_needed and self._should_refresh(credential): - credential = self._refresh_credential(credential) - - # Cache - self._add_to_cache(credential) - - return credential - - def get_key(self, credential_id: str, key_name: str) -> Optional[str]: - """Convenience method to get a specific key value.""" - credential = self.get_credential(credential_id) - if credential is None: - return None - return credential.get_key(key_name) - - def get(self, credential_id: str) -> Optional[str]: - """ - Legacy compatibility: get the primary key value. - - For single-key credentials, returns that key. - For multi-key, returns 'value', 'api_key', or 'access_token'. - """ - credential = self.get_credential(credential_id) - if credential is None: - return None - - for key_name in ["value", "api_key", "access_token"]: - if key_name in credential.keys: - return credential.get_key(key_name) - - if credential.keys: - first_key = next(iter(credential.keys)) - return credential.get_key(first_key) - - return None - - # --- Template Resolution --- - - def resolve(self, template: str) -> str: - """Resolve credential templates in a string.""" - return self._resolver.resolve(template) - - def resolve_headers(self, headers: Dict[str, str]) -> Dict[str, str]: - """Resolve credential templates in headers dictionary.""" - return self._resolver.resolve_headers(headers) - - def resolve_for_usage(self, credential_id: str) -> Dict[str, str]: - """Get resolved headers for a registered usage spec.""" - spec = self._usage_specs.get(credential_id) - if spec is None: - raise ValueError(f"No usage spec registered for '{credential_id}'") - return self.resolve_headers(spec.headers) - - # --- Credential Management --- - - def save_credential(self, credential: CredentialObject) -> None: - """Save a credential to storage.""" - with self._lock: - self._storage.save(credential) - self._add_to_cache(credential) - - def delete_credential(self, credential_id: str) -> bool: - """Delete a credential.""" - with self._lock: - self._remove_from_cache(credential_id) - return self._storage.delete(credential_id) - - def list_credentials(self) -> List[str]: - """List all available credential IDs.""" - return self._storage.list_all() - - def is_available(self, credential_id: str) -> bool: - """Check if a credential is available.""" - return self.get_credential(credential_id, refresh_if_needed=False) is not None - - # --- Validation --- - - def validate_for_usage(self, credential_id: str) -> List[str]: - """ - Validate that a credential meets its usage spec requirements. - - Returns list of missing keys or empty list if valid. - """ - spec = self._usage_specs.get(credential_id) - if spec is None: - return [] - - credential = self.get_credential(credential_id) - if credential is None: - return [f"Credential '{credential_id}' not found"] - - missing = [] - for key_name in spec.required_keys: - if key_name not in credential.keys: - missing.append(key_name) - - return missing - - # --- Factory Methods --- - - @classmethod - def for_testing( - cls, - credentials: Dict[str, Dict[str, str]] - ) -> "CredentialStore": - """ - Create a credential store for testing with mock credentials. - - Args: - credentials: Dict mapping credential_id to {key_name: value} - e.g., {"brave_search": {"api_key": "test-key"}} - - Returns: - CredentialStore with in-memory credentials - """ - # ... implementation ... -``` - ---- - -## OAuth2 Module - -**Location**: `core/framework/credentials/oauth2/` - -### OAuth2Token - -```python -@dataclass -class OAuth2Token: - """Represents an OAuth2 token with metadata.""" - access_token: str - token_type: str = "Bearer" - expires_at: Optional[datetime] = None - refresh_token: Optional[str] = None - scope: Optional[str] = None - raw_response: dict[str, Any] = field(default_factory=dict) - - @property - def is_expired(self) -> bool: - """Check if token is expired (with 5-minute buffer).""" - if self.expires_at is None: - return False - return datetime.utcnow() >= (self.expires_at - timedelta(minutes=5)) - - @property - def can_refresh(self) -> bool: - """Check if token can be refreshed.""" - return self.refresh_token is not None -``` - -### OAuth2Config - -```python -@dataclass -class OAuth2Config: - """Configuration for an OAuth2 provider.""" - token_url: str - authorization_url: Optional[str] = None - revocation_url: Optional[str] = None - - client_id: str = "" - client_secret: str = "" - default_scopes: list[str] = field(default_factory=list) - - # Token placement for requests (bipartisan model) - token_placement: TokenPlacement = TokenPlacement.HEADER_BEARER - custom_header_name: Optional[str] = None - - request_timeout: float = 30.0 - extra_token_params: dict[str, str] = field(default_factory=dict) - - -class TokenPlacement(Enum): - """Where to place the access token in requests.""" - HEADER_BEARER = "header_bearer" # Authorization: Bearer - HEADER_CUSTOM = "header_custom" # Custom header name - QUERY_PARAM = "query_param" # ?access_token= -``` - -### BaseOAuth2Provider - -```python -class BaseOAuth2Provider(CredentialProvider): - """ - Generic OAuth2 provider implementation. - - Works with standard OAuth2 servers. Override methods for - provider-specific behavior. - - OSS users can extend this class for custom providers. - """ - - def __init__(self, config: OAuth2Config): - self.config = config - self._client = httpx.Client(timeout=config.request_timeout) - - def client_credentials_grant( - self, - scopes: Optional[list[str]] = None, - ) -> OAuth2Token: - """Obtain token using client credentials flow.""" - data = { - "grant_type": "client_credentials", - "client_id": self.config.client_id, - "client_secret": self.config.client_secret, - **self.config.extra_token_params, - } - - if scopes or self.config.default_scopes: - data["scope"] = " ".join(scopes or self.config.default_scopes) - - return self._token_request(data) - - def refresh_token( - self, - refresh_token: str, - scopes: Optional[list[str]] = None, - ) -> OAuth2Token: - """Refresh access token using refresh_token grant.""" - data = { - "grant_type": "refresh_token", - "client_id": self.config.client_id, - "client_secret": self.config.client_secret, - "refresh_token": refresh_token, - **self.config.extra_token_params, - } - - if scopes: - data["scope"] = " ".join(scopes) - - return self._token_request(data) - - def refresh(self, credential: CredentialObject) -> CredentialObject: - """Implement CredentialProvider.refresh().""" - refresh_tok = credential.get_key("refresh_token") - if not refresh_tok: - raise CredentialRefreshError( - f"Credential '{credential.id}' has no refresh_token" - ) - - new_token = self.refresh_token(refresh_tok) - - credential.set_key( - "access_token", - new_token.access_token, - expires_at=new_token.expires_at - ) - - if new_token.refresh_token: - credential.set_key("refresh_token", new_token.refresh_token) - - credential.last_refreshed = datetime.utcnow() - return credential - - def validate(self, credential: CredentialObject) -> bool: - """Check if access_token exists and is not expired.""" - access_key = credential.keys.get("access_token") - if access_key is None: - return False - return not access_key.is_expired - - def format_for_request(self, token: OAuth2Token) -> dict[str, Any]: - """Format token for HTTP requests (bipartisan model).""" - placement = self.config.token_placement - - if placement == TokenPlacement.HEADER_BEARER: - return { - "headers": { - "Authorization": f"{token.token_type} {token.access_token}" - } - } - elif placement == TokenPlacement.HEADER_CUSTOM: - return { - "headers": { - self.config.custom_header_name: token.access_token - } - } - elif placement == TokenPlacement.QUERY_PARAM: - return { - "params": {"access_token": token.access_token} - } - - return {} - - # ... _token_request helper ... -``` - -### TokenLifecycleManager - -```python -class TokenLifecycleManager: - """ - Manages the complete lifecycle of OAuth2 tokens. - - Responsibilities: - - Coordinate with CredentialStore for persistence - - Automatically refresh expired tokens - - Handle refresh failures gracefully - """ - - def __init__( - self, - provider: BaseOAuth2Provider, - credential_name: str, - store: CredentialStore, - refresh_buffer_minutes: int = 5, - ): - self.provider = provider - self.credential_name = credential_name - self.store = store - self.refresh_buffer = timedelta(minutes=refresh_buffer_minutes) - self._cached_token: Optional[OAuth2Token] = None - - async def get_valid_token(self) -> Optional[OAuth2Token]: - """Get a valid access token, refreshing if necessary.""" - credential = self.store.get_credential(self.credential_name) - if credential is None: - return None - - # Build OAuth2Token from credential - token = self._credential_to_token(credential) - - if self._needs_refresh(token): - token = await self._refresh_token(credential) - - return token - - async def acquire_token_client_credentials( - self, - scopes: Optional[list[str]] = None, - ) -> OAuth2Token: - """Acquire a new token using client credentials flow.""" - token = self.provider.client_credentials_grant(scopes=scopes) - self._save_token_to_store(token) - return token - - # ... helper methods ... -``` - ---- - -## HashiCorp Vault Integration - -**Location**: `core/framework/credentials/vault/hashicorp.py` - -HashiCorp Vault provides enterprise-grade secret management with: -- Dynamic secrets -- Lease management -- Audit logging -- Access policies - -### HashiCorpVaultStorage - -```python -class HashiCorpVaultStorage(CredentialStorage): - """ - HashiCorp Vault storage adapter. - - Requires: uv pip install hvac - - Features: - - KV v2 secrets engine support - - Automatic lease renewal - - Namespace support (Enterprise) - - Example: - storage = HashiCorpVaultStorage( - url="https://vault.example.com:8200", - token="hvs.xxx", # Or use VAULT_TOKEN env var - mount_point="secret", - path_prefix="hive/credentials" - ) - - store = CredentialStore(storage=storage) - """ - - def __init__( - self, - url: str, - token: Optional[str] = None, - mount_point: str = "secret", - path_prefix: str = "hive/credentials", - namespace: Optional[str] = None, - ): - """ - Initialize Vault storage. - - Args: - url: Vault server URL (e.g., https://vault.example.com:8200) - token: Vault token. If None, reads from VAULT_TOKEN env var - mount_point: KV secrets engine mount point - path_prefix: Path prefix for all credentials - namespace: Vault namespace (Enterprise feature) - """ - try: - import hvac - except ImportError: - raise ImportError( - "HashiCorp Vault support requires 'hvac'. " - "Install with: uv pip install hvac" - ) - - import os - - self._url = url - self._token = token or os.environ.get("VAULT_TOKEN") - self._mount = mount_point - self._prefix = path_prefix - self._namespace = namespace - - self._client = hvac.Client( - url=url, - token=self._token, - namespace=namespace - ) - - if not self._client.is_authenticated(): - raise ValueError( - "Vault authentication failed. Check VAULT_TOKEN or token parameter." - ) - - def save(self, credential: CredentialObject) -> None: - """Save credential to Vault KV v2.""" - path = self._path(credential.id) - data = self._serialize_for_vault(credential) - - self._client.secrets.kv.v2.create_or_update_secret( - path=path, - secret=data, - mount_point=self._mount - ) - - def load(self, credential_id: str) -> Optional[CredentialObject]: - """Load credential from Vault.""" - path = self._path(credential_id) - - try: - response = self._client.secrets.kv.v2.read_secret_version( - path=path, - mount_point=self._mount - ) - data = response["data"]["data"] - return self._deserialize_from_vault(credential_id, data) - except Exception as e: - logger.debug(f"Credential not found at {path}: {e}") - return None - - def delete(self, credential_id: str) -> bool: - """Delete credential from Vault.""" - path = self._path(credential_id) - try: - self._client.secrets.kv.v2.delete_metadata_and_all_versions( - path=path, - mount_point=self._mount - ) - return True - except Exception: - return False - - def list_all(self) -> List[str]: - """List all credentials under the prefix.""" - try: - response = self._client.secrets.kv.v2.list_secrets( - path=self._prefix, - mount_point=self._mount - ) - return response["data"]["keys"] - except Exception: - return [] - - def exists(self, credential_id: str) -> bool: - return self.load(credential_id) is not None - - def _path(self, credential_id: str) -> str: - """Build Vault path for credential.""" - return f"{self._prefix}/{credential_id}" - - def _serialize_for_vault(self, credential: CredentialObject) -> dict: - """Convert credential to Vault secret format.""" - data = {"_type": credential.credential_type.value} - - for key_name, key in credential.keys.items(): - data[key_name] = key.get_secret_value() - if key.expires_at: - data[f"_expires_{key_name}"] = key.expires_at.isoformat() - - if credential.provider_id: - data["_provider_id"] = credential.provider_id - - return data - - def _deserialize_from_vault( - self, - credential_id: str, - data: dict - ) -> CredentialObject: - """Reconstruct credential from Vault secret.""" - cred_type = CredentialType(data.pop("_type", "api_key")) - provider_id = data.pop("_provider_id", None) - - keys = {} - for key, value in list(data.items()): - if key.startswith("_"): - continue - - expires_at = None - expires_key = f"_expires_{key}" - if expires_key in data: - expires_at = datetime.fromisoformat(data[expires_key]) - - keys[key] = CredentialKey( - name=key, - value=SecretStr(value), - expires_at=expires_at - ) - - return CredentialObject( - id=credential_id, - credential_type=cred_type, - keys=keys, - provider_id=provider_id - ) -``` - -### Vault Configuration Example - -```python -# Example: Setting up HashiCorp Vault integration - -from framework.credentials.store import CredentialStore -from framework.credentials.vault.hashicorp import HashiCorpVaultStorage -from framework.credentials.oauth2 import BaseOAuth2Provider, OAuth2Config - -# 1. Configure Vault storage -vault_storage = HashiCorpVaultStorage( - url="https://vault.mycompany.com:8200", - # token read from VAULT_TOKEN env var - mount_point="secret", - path_prefix="hive/agents/prod" -) - -# 2. Configure OAuth2 provider -github_oauth = BaseOAuth2Provider(OAuth2Config( - token_url="https://github.com/login/oauth/access_token", - client_id="your-client-id", - client_secret="your-client-secret", # Or fetch from Vault -)) - -# 3. Create credential store -store = CredentialStore( - storage=vault_storage, - providers=[github_oauth] -) - -# 4. Use credentials -headers = store.resolve_headers({ - "Authorization": "Bearer {{github_oauth.access_token}}" -}) -``` - ---- - -## Backward Compatibility - -**Location**: `tools/src/aden_tools/credentials/store_adapter.py` - -### CredentialStoreAdapter - -```python -class CredentialStoreAdapter(CredentialManager): - """ - Adapter that makes CredentialStore compatible with existing CredentialManager API. - - This allows gradual migration: existing tools continue to work while - new features are available. - - Usage: - from framework.credentials.store import CredentialStore - from aden_tools.credentials.store_adapter import CredentialStoreAdapter - - store = CredentialStore(...) - credentials = CredentialStoreAdapter(store) - - # Existing API works unchanged - api_key = credentials.get("brave_search") - credentials.validate_for_tools(["web_search"]) - - # New features also available - headers = credentials.resolve_headers({ - "Authorization": "Bearer {{github_oauth.access_token}}" - }) - """ - - def __init__( - self, - store: CredentialStore, - specs: Optional[Dict[str, CredentialSpec]] = None, - ): - # Note: Don't call parent __init__ - we're replacing its behavior - if specs is None: - from . import CREDENTIAL_SPECS - specs = CREDENTIAL_SPECS - - self._store = store - self._specs = specs - - # Build tool -> credential mapping - self._tool_to_cred: Dict[str, str] = {} - for cred_name, spec in self._specs.items(): - for tool_name in spec.tools: - self._tool_to_cred[tool_name] = cred_name - - def get(self, name: str) -> Optional[str]: - """Get credential value using the new store.""" - return self._store.get(name) - - def is_available(self, name: str) -> bool: - """Check if credential is available.""" - return self._store.is_available(name) - - def validate_for_tools(self, tool_names: List[str]) -> None: - """Validate credentials for tools.""" - missing = self.get_missing_for_tools(tool_names) - if missing: - raise CredentialError(self._format_missing_error(missing, tool_names)) - - # --- New Methods --- - - def resolve_headers(self, headers: Dict[str, str]) -> Dict[str, str]: - """Resolve credential templates in headers.""" - return self._store.resolve_headers(headers) - - def get_key(self, credential_id: str, key_name: str) -> Optional[str]: - """Get a specific key from a multi-key credential.""" - return self._store.get_key(credential_id, key_name) - - @property - def store(self) -> CredentialStore: - """Access the underlying credential store.""" - return self._store -``` - ---- - -## Usage Examples - -### Example 1: Simple API Key (Backward Compatible) - -```python -# Existing code continues to work without changes -from aden_tools.credentials import CredentialManager - -credentials = CredentialManager() -api_key = credentials.get("brave_search") - -# Tool uses it directly -response = httpx.get( - "https://api.search.brave.com/res/v1/web/search", - headers={"X-Subscription-Token": api_key} -) -``` - -### Example 2: Multi-Key Credential with Templates - -```python -from framework.credentials.store import CredentialStore -from framework.credentials.storage import EncryptedFileStorage - -# Create store with encrypted storage -store = CredentialStore( - storage=EncryptedFileStorage("~/.hive/credentials") -) - -# Tool specifies how to use credentials (bipartisan model) -headers = store.resolve_headers({ - "Authorization": "Bearer {{github_oauth.access_token}}", - "X-API-Key": "{{brave_search.api_key}}" -}) - -response = httpx.get("https://api.example.com", headers=headers) -``` - -### Example 3: OAuth2 with Auto-Refresh - -```python -from framework.credentials.store import CredentialStore -from framework.credentials.oauth2 import BaseOAuth2Provider, OAuth2Config - -# Configure OAuth2 provider -provider = BaseOAuth2Provider(OAuth2Config( - token_url="https://accounts.google.com/o/oauth2/token", - client_id="your-client-id", - client_secret="your-client-secret", - default_scopes=["https://www.googleapis.com/auth/drive.readonly"] -)) - -store = CredentialStore(providers=[provider]) - -# Save OAuth2 credential -from framework.credentials.models import CredentialObject, CredentialKey, CredentialType -from pydantic import SecretStr - -store.save_credential(CredentialObject( - id="google_drive", - credential_type=CredentialType.OAUTH2, - keys={ - "access_token": CredentialKey( - name="access_token", - value=SecretStr("ya29.xxx"), - expires_at=datetime.utcnow() + timedelta(hours=1) - ), - "refresh_token": CredentialKey( - name="refresh_token", - value=SecretStr("1//xxx") - ) - }, - provider_id="oauth2", - auto_refresh=True -)) - -# Token auto-refreshes when expired -token = store.get_key("google_drive", "access_token") -``` - -### Example 4: Custom Provider (OSS Extensibility) - -```python -from framework.credentials.provider import CredentialProvider, CredentialRefreshError -from framework.credentials.models import CredentialObject, CredentialType - -class MyCustomProvider(CredentialProvider): - """Provider for my custom auth system.""" - - @property - def provider_id(self) -> str: - return "my_custom_auth" - - @property - def supported_types(self) -> list[CredentialType]: - return [CredentialType.CUSTOM] - - def refresh(self, credential: CredentialObject) -> CredentialObject: - # Custom refresh logic - api_key = credential.get_key("api_key") - - # Call your auth API - response = requests.post( - "https://auth.myservice.com/refresh", - headers={"X-API-Key": api_key} - ) - data = response.json() - - credential.set_key( - "access_token", - data["token"], - expires_at=datetime.fromisoformat(data["expires_at"]) - ) - return credential - - def validate(self, credential: CredentialObject) -> bool: - token = credential.get_key("access_token") - response = requests.get( - "https://auth.myservice.com/validate", - headers={"Authorization": f"Bearer {token}"} - ) - return response.status_code == 200 - -# Register with store -store.register_provider(MyCustomProvider()) -``` - -### Example 5: HashiCorp Vault in Production - -```python -from framework.credentials.store import CredentialStore -from framework.credentials.vault.hashicorp import HashiCorpVaultStorage - -# Production setup with Vault -storage = HashiCorpVaultStorage( - url="https://vault.prod.mycompany.com:8200", - mount_point="secret", - path_prefix="hive/agents/production", - namespace="team-ai" # Enterprise namespace -) - -store = CredentialStore(storage=storage) - -# Credentials are stored/retrieved from Vault -api_key = store.get("openai") -``` - ---- - -## Implementation Plan - -### Phase 1: Core Infrastructure (Days 1-2) - -| File | Description | -|------|-------------| -| `core/framework/credentials/__init__.py` | Public exports | -| `core/framework/credentials/models.py` | CredentialObject, CredentialKey, CredentialUsageSpec | -| `core/framework/credentials/template.py` | TemplateResolver for {{cred.key}} patterns | -| `core/framework/credentials/storage.py` | CredentialStorage ABC, EncryptedFileStorage, EnvVarStorage | -| `core/framework/credentials/provider.py` | CredentialProvider ABC, StaticProvider | - -### Phase 2: Main Store (Days 2-3) - -| File | Description | -|------|-------------| -| `core/framework/credentials/store.py` | CredentialStore orchestrator | -| `tools/src/aden_tools/credentials/store_adapter.py` | Backward compatibility adapter | - -### Phase 3: OAuth2 Support (Days 3-4) - -| File | Description | -|------|-------------| -| `core/framework/credentials/oauth2/__init__.py` | OAuth2 module exports | -| `core/framework/credentials/oauth2/provider.py` | OAuth2Token, OAuth2Config, TokenPlacement | -| `core/framework/credentials/oauth2/base_provider.py` | BaseOAuth2Provider | -| `core/framework/credentials/oauth2/lifecycle.py` | TokenLifecycleManager | - -### Phase 4: Vault Integration (Days 4-5) - -| File | Description | -|------|-------------| -| `core/framework/credentials/vault/__init__.py` | Vault module exports | -| `core/framework/credentials/vault/hashicorp.py` | HashiCorpVaultStorage | - -### Phase 5: Integration & Testing (Days 5-6) - -| Task | Description | -|------|-------------| -| Update `tools/mcp_server.py` | Integrate new CredentialStore | -| Update tool registrations | Migrate to template-based usage | -| Comprehensive test suite | Unit and integration tests | -| Documentation | Update README, add examples | - ---- - -## Security Considerations - -### Encryption - -1. **At-Rest Encryption**: EncryptedFileStorage uses Fernet (AES-128-CBC + HMAC) -2. **Master Key**: Read from `HIVE_CREDENTIAL_KEY` environment variable -3. **Key Generation**: Fernet.generate_key() for new installations - -### Secret Handling - -1. **SecretStr**: Pydantic's SecretStr prevents accidental logging -2. **Memory**: Secrets cleared from cache after TTL expires -3. **Transmission**: Never logged or printed in errors - -### Thread Safety - -1. **RLock**: All store operations protected by reentrant lock -2. **Cache**: Thread-safe read/write with TTL expiration - -### Vault Security - -1. **Authentication**: Token-based auth, supports VAULT_TOKEN env var -2. **Namespaces**: Enterprise namespace support for isolation -3. **Audit**: Vault provides comprehensive audit logging - ---- - -## File Structure Summary - -``` -core/framework/credentials/ -├── __init__.py # Public exports -├── models.py # CredentialObject, CredentialKey, CredentialUsageSpec -├── store.py # CredentialStore (main orchestrator) -├── storage.py # CredentialStorage ABC + EncryptedFileStorage, EnvVarStorage -├── provider.py # CredentialProvider ABC + StaticProvider -├── template.py # TemplateResolver for {{cred.key}} -├── oauth2/ -│ ├── __init__.py -│ ├── provider.py # OAuth2Token, OAuth2Config, TokenPlacement -│ ├── base_provider.py # BaseOAuth2Provider -│ └── lifecycle.py # TokenLifecycleManager -└── vault/ - ├── __init__.py - └── hashicorp.py # HashiCorpVaultStorage - -tools/src/aden_tools/credentials/ -├── (existing files) -└── store_adapter.py # CredentialStoreAdapter for backward compat -``` - ---- - -## Verification Plan - -### Unit Tests - -- [ ] CredentialObject CRUD operations -- [ ] TemplateResolver with various patterns -- [ ] EncryptedFileStorage encryption/decryption -- [ ] EnvVarStorage hot-reload -- [ ] StaticProvider validation -- [ ] OAuth2 token refresh flow -- [ ] HashiCorpVaultStorage operations (mocked) - -### Integration Tests - -- [ ] End-to-end credential flow -- [ ] Template resolution in HTTP headers -- [ ] OAuth2 auto-refresh with lifecycle manager -- [ ] Backward compatibility with existing tools - -### Manual Testing - -- [ ] Create local encrypted store -- [ ] Save and load multi-key credentials -- [ ] Verify template resolution in tool headers -- [ ] Test OAuth2 token refresh -- [ ] Verify existing tools continue working -- [ ] Test Vault integration (if Vault available) diff --git a/docs/credential-store-usage.md b/docs/credential-store-usage.md deleted file mode 100644 index 3dd47bd874..0000000000 --- a/docs/credential-store-usage.md +++ /dev/null @@ -1,992 +0,0 @@ -# Credential Store Usage Guide - -This guide covers how to use the Hive credential store for managing API keys, OAuth2 tokens, and custom credentials in your agents and tools. - -## Table of Contents - -- [Quick Start](#quick-start) -- [Core Concepts](#core-concepts) -- [Basic Usage](#basic-usage) -- [Template Resolution](#template-resolution) -- [Storage Backends](#storage-backends) -- [Using OAuth2 Provider](#using-oauth2-provider) -- [Implementing Custom Providers](#implementing-custom-providers) -- [Testing with Credentials](#testing-with-credentials) -- [Migration from CredentialManager](#migration-from-credentialmanager) -- [Security Best Practices](#security-best-practices) - ---- - -## Quick Start - -```python -from core.framework.credentials import CredentialStore, InMemoryStorage - -# Create a store with in-memory storage (for development) -store = CredentialStore(storage=InMemoryStorage()) - -# Save a simple API key -store.save_api_key("brave_search", "your-api-key-here") - -# Retrieve the credential -api_key = store.get("brave_search") - -# Use template resolution for HTTP headers -headers = store.resolve_headers({ - "X-Subscription-Token": "{{brave_search.api_key}}" -}) -# Result: {"X-Subscription-Token": "your-api-key-here"} -``` - ---- - -## Core Concepts - -### Key-Vault Structure - -Credentials are stored as **objects** containing one or more **keys**: - -``` -brave_search (CredentialObject) -├── api_key: "BSAKxxxxx" - -github_oauth (CredentialObject) -├── access_token: "ghp_xxxxx" -├── refresh_token: "ghr_xxxxx" -└── expires_at: 2024-01-15T10:00:00Z -``` - -### Bipartisan Model - -The credential store follows a **bipartisan model**: -- **Store**: Only stores credential values -- **Tools**: Define how credentials are used (headers, query params, etc.) - -This separation keeps the store simple and lets each tool specify its exact requirements. - -### Components - -| Component | Purpose | -|-----------|---------| -| `CredentialStore` | Main orchestrator for all credential operations | -| `CredentialObject` | A credential with one or more keys | -| `CredentialKey` | A single key-value pair with optional expiration | -| `CredentialStorage` | Backend for persisting credentials | -| `CredentialProvider` | Handles credential lifecycle (refresh, validate) | -| `TemplateResolver` | Resolves `{{cred.key}}` patterns | - ---- - -## Basic Usage - -### Creating a Credential Store - -```python -from core.framework.credentials import ( - CredentialStore, - EncryptedFileStorage, - EnvVarStorage, - InMemoryStorage, -) - -# Option 1: Encrypted file storage (recommended for production) -store = CredentialStore.with_encrypted_storage("~/.hive/credentials") - -# Option 2: Environment variable storage (backward compatible) -store = CredentialStore.with_env_storage({ - "brave_search": "BRAVE_SEARCH_API_KEY", - "openai": "OPENAI_API_KEY", -}) - -# Option 3: In-memory storage (for testing/development) -store = CredentialStore(storage=InMemoryStorage()) - -# Option 4: Custom storage configuration -storage = EncryptedFileStorage( - base_path="~/.hive/credentials", - key_env_var="HIVE_CREDENTIAL_KEY" # Encryption key from env -) -store = CredentialStore(storage=storage) -``` - -### Saving Credentials - -```python -# Simple API key -store.save_api_key("brave_search", "your-api-key") - -# Multi-key credential (e.g., OAuth2) -from core.framework.credentials import CredentialObject, CredentialKey, CredentialType -from pydantic import SecretStr -from datetime import datetime, timedelta, timezone - -credential = CredentialObject( - id="github_oauth", - credential_type=CredentialType.OAUTH2, - keys={ - "access_token": CredentialKey( - name="access_token", - value=SecretStr("ghp_xxxxxxxxxxxx"), - expires_at=datetime.now(timezone.utc) + timedelta(hours=1) - ), - "refresh_token": CredentialKey( - name="refresh_token", - value=SecretStr("ghr_xxxxxxxxxxxx") - ), - }, - provider_id="oauth2", - auto_refresh=True, -) -store.save_credential(credential) -``` - -### Retrieving Credentials - -```python -# Get the default key value (api_key, access_token, or first key) -api_key = store.get("brave_search") - -# Get a specific key -access_token = store.get_key("github_oauth", "access_token") -refresh_token = store.get_key("github_oauth", "refresh_token") - -# Get the full credential object -credential = store.get_credential("github_oauth") -if credential: - print(f"Type: {credential.credential_type}") - print(f"Keys: {list(credential.keys.keys())}") - print(f"Auto-refresh: {credential.auto_refresh}") - -# Check if credential exists and is available -if store.is_available("brave_search"): - # Use the credential - pass -``` - -### Deleting Credentials - -```python -# Delete a credential -deleted = store.delete_credential("old_api_key") -if deleted: - print("Credential deleted") -``` - ---- - -## Template Resolution - -The credential store supports template patterns for injecting credentials into HTTP requests. - -### Syntax - -``` -{{credential_id}} -> Returns default key -{{credential_id.key_name}} -> Returns specific key -``` - -### Resolving Headers - -```python -# Define headers with credential templates -header_templates = { - "Authorization": "Bearer {{github_oauth.access_token}}", - "X-API-Key": "{{brave_search.api_key}}", - "X-Custom": "{{custom_cred.token}}" -} - -# Resolve to actual values -headers = store.resolve_headers(header_templates) -# Result: { -# "Authorization": "Bearer ghp_xxxxxxxxxxxx", -# "X-API-Key": "BSAKxxxxxxxxxxxx", -# "X-Custom": "actual-token-value" -# } - -# Use with httpx/requests -import httpx -response = httpx.get("https://api.example.com/data", headers=headers) -``` - -### Resolving Query Parameters - -```python -params = store.resolve_params({ - "api_key": "{{brave_search.api_key}}", - "client_id": "{{oauth_app.client_id}}" -}) -``` - -### Resolving Arbitrary Strings - -```python -# Resolve any string containing templates -url = store.resolve("https://api.example.com?key={{api_cred.key}}") -``` - -### Handling Missing Credentials - -```python -# By default, missing credentials raise an error -try: - headers = store.resolve_headers({"Auth": "{{missing.key}}"}) -except CredentialNotFoundError as e: - print(f"Missing credential: {e}") - -# Use fail_on_missing=False to leave templates unresolved -headers = store.resolve_headers( - {"Auth": "{{missing.key}}"}, - fail_on_missing=False -) -# Result: {"Auth": "{{missing.key}}"} -``` - ---- - -## Storage Backends - -### EncryptedFileStorage (Recommended) - -Encrypts credentials at rest using Fernet (AES-128-CBC + HMAC). - -```python -from core.framework.credentials import EncryptedFileStorage - -# The encryption key is read from HIVE_CREDENTIAL_KEY env var -storage = EncryptedFileStorage("~/.hive/credentials") - -# Or provide the key directly (32-byte Fernet key) -storage = EncryptedFileStorage( - base_path="~/.hive/credentials", - encryption_key=b"your-32-byte-fernet-key-here..." -) -``` - -**Directory structure:** -``` -~/.hive/credentials/ -├── credentials/ -│ ├── brave_search.enc # Encrypted credential JSON -│ └── github_oauth.enc -└── metadata/ - └── index.json # Unencrypted index -``` - -**Generate an encryption key:** -```python -from cryptography.fernet import Fernet -key = Fernet.generate_key() -print(f"HIVE_CREDENTIAL_KEY={key.decode()}") -``` - -### EnvVarStorage (Backward Compatible) - -Reads credentials from environment variables. **Read-only** - cannot save credentials. - -```python -from core.framework.credentials import EnvVarStorage - -storage = EnvVarStorage( - env_mapping={ - "brave_search": "BRAVE_SEARCH_API_KEY", - "openai": "OPENAI_API_KEY", - } -) - -# Credentials are read from environment -# export BRAVE_SEARCH_API_KEY=your-key -``` - -### CompositeStorage (Layered) - -Combines multiple storage backends with fallback. - -```python -from core.framework.credentials import CompositeStorage, EncryptedFileStorage, EnvVarStorage - -storage = CompositeStorage( - primary=EncryptedFileStorage("~/.hive/credentials"), - fallbacks=[ - EnvVarStorage({"brave_search": "BRAVE_SEARCH_API_KEY"}) - ] -) - -# Writes go to primary (encrypted files) -# Reads check primary first, then fallbacks (env vars) -``` - -### HashiCorp Vault Storage - -For enterprise deployments with HashiCorp Vault. - -```python -from core.framework.credentials.vault import HashiCorpVaultStorage - -storage = HashiCorpVaultStorage( - vault_url="https://vault.example.com", - token="hvs.xxxxx", # Or use VAULT_TOKEN env var - mount_point="secret", - path_prefix="hive/credentials" -) -``` - ---- - -## Using OAuth2 Provider - -The OAuth2 provider handles token lifecycle including automatic refresh. - -### Setup - -```python -from core.framework.credentials import CredentialStore, InMemoryStorage -from core.framework.credentials.oauth2 import BaseOAuth2Provider, OAuth2Config - -# Configure OAuth2 -config = OAuth2Config( - token_url="https://oauth.example.com/token", - authorization_url="https://oauth.example.com/authorize", # Optional - client_id="your-client-id", - client_secret="your-client-secret", - default_scopes=["read", "write"], -) - -# Create provider -provider = BaseOAuth2Provider(config) - -# Create store with provider -store = CredentialStore( - storage=InMemoryStorage(), - providers=[provider], -) -``` - -### Client Credentials Flow (Server-to-Server) - -```python -# Get a token using client credentials -token = provider.client_credentials_grant(scopes=["api.read"]) - -# Save to store -from core.framework.credentials import CredentialObject, CredentialKey, CredentialType -from pydantic import SecretStr - -credential = CredentialObject( - id="service_account", - credential_type=CredentialType.OAUTH2, - keys={ - "access_token": CredentialKey( - name="access_token", - value=SecretStr(token.access_token), - expires_at=token.expires_at - ), - }, - provider_id="oauth2", - auto_refresh=True, -) -store.save_credential(credential) -``` - -### Refresh Token Flow - -```python -# Save credential with refresh token -credential = CredentialObject( - id="user_oauth", - credential_type=CredentialType.OAUTH2, - keys={ - "access_token": CredentialKey( - name="access_token", - value=SecretStr("ghp_xxxx"), - expires_at=datetime.now(timezone.utc) + timedelta(hours=1) - ), - "refresh_token": CredentialKey( - name="refresh_token", - value=SecretStr("ghr_xxxx") - ), - }, - provider_id="oauth2", - auto_refresh=True, -) -store.save_credential(credential) - -# When you retrieve the credential, it auto-refreshes if expired -token = store.get("user_oauth") # Automatically refreshed if needed - -# Or manually refresh -store.refresh_credential("user_oauth") -``` - -### Token Lifecycle Manager - -For more control over token lifecycle: - -```python -from core.framework.credentials.oauth2 import TokenLifecycleManager -from datetime import timedelta - -manager = TokenLifecycleManager( - credential_id="my_oauth", - provider=provider, - store=store, - refresh_buffer=timedelta(minutes=5), # Refresh 5 min before expiry -) - -# Acquire token (refreshes if needed) -token = await manager.acquire_token() - -# Use the token -headers = {"Authorization": f"Bearer {token.access_token}"} -``` - ---- - -## Implementing Custom Providers - -Custom providers let you integrate with proprietary authentication systems. - -### Provider Interface - -```python -from abc import ABC, abstractmethod -from typing import List -from core.framework.credentials import CredentialObject, CredentialType - -class CredentialProvider(ABC): - """Abstract base for credential providers.""" - - @property - @abstractmethod - def provider_id(self) -> str: - """Unique identifier for this provider.""" - pass - - @property - @abstractmethod - def supported_types(self) -> List[CredentialType]: - """Credential types this provider handles.""" - pass - - @abstractmethod - def refresh(self, credential: CredentialObject) -> CredentialObject: - """Refresh the credential and return updated version.""" - pass - - @abstractmethod - def validate(self, credential: CredentialObject) -> bool: - """Check if credential is still valid.""" - pass - - def should_refresh(self, credential: CredentialObject) -> bool: - """Determine if credential needs refresh (optional override).""" - # Default: check expiration with 5-minute buffer - ... - - def revoke(self, credential: CredentialObject) -> bool: - """Revoke credential (optional, default returns False).""" - return False -``` - -### Example: Custom API Provider - -```python -from datetime import datetime, timedelta, timezone -from typing import List - -from pydantic import SecretStr - -from core.framework.credentials import ( - CredentialKey, - CredentialObject, - CredentialProvider, - CredentialRefreshError, - CredentialType, -) - - -class MyCustomProvider(CredentialProvider): - """ - Custom provider for MyService API tokens. - - MyService issues tokens that expire after 24 hours and can be - refreshed using the original API key. - """ - - def __init__(self, base_url: str = "https://api.myservice.com"): - self.base_url = base_url - - @property - def provider_id(self) -> str: - return "myservice" - - @property - def supported_types(self) -> List[CredentialType]: - return [CredentialType.CUSTOM] - - def refresh(self, credential: CredentialObject) -> CredentialObject: - """Refresh the access token using the API key.""" - import httpx - - api_key = credential.get_key("api_key") - if not api_key: - raise CredentialRefreshError( - f"Credential '{credential.id}' missing api_key for refresh" - ) - - # Call MyService API to get new token - try: - response = httpx.post( - f"{self.base_url}/auth/token", - headers={"X-API-Key": api_key}, - timeout=30, - ) - response.raise_for_status() - data = response.json() - except httpx.HTTPError as e: - raise CredentialRefreshError(f"Token refresh failed: {e}") from e - - # Update credential with new token - credential.set_key( - "access_token", - data["access_token"], - expires_at=datetime.now(timezone.utc) + timedelta(hours=24), - ) - credential.last_refreshed = datetime.now(timezone.utc) - - return credential - - def validate(self, credential: CredentialObject) -> bool: - """Check if access_token exists and is not expired.""" - access_key = credential.keys.get("access_token") - if access_key is None: - return False - return not access_key.is_expired - - def should_refresh(self, credential: CredentialObject) -> bool: - """Refresh if token expires within 1 hour.""" - access_key = credential.keys.get("access_token") - if access_key is None or access_key.expires_at is None: - return False - - buffer = timedelta(hours=1) - return datetime.now(timezone.utc) >= (access_key.expires_at - buffer) - - def revoke(self, credential: CredentialObject) -> bool: - """Revoke the access token.""" - import httpx - - access_token = credential.get_key("access_token") - if not access_token: - return False - - try: - response = httpx.post( - f"{self.base_url}/auth/revoke", - headers={"Authorization": f"Bearer {access_token}"}, - timeout=30, - ) - return response.status_code == 200 - except httpx.HTTPError: - return False -``` - -### Registering Custom Providers - -```python -from core.framework.credentials import CredentialStore, InMemoryStorage - -# Create store with custom provider -provider = MyCustomProvider(base_url="https://api.myservice.com") -store = CredentialStore( - storage=InMemoryStorage(), - providers=[provider], -) - -# Or register after creation -store.register_provider(provider) - -# Save a credential that uses this provider -credential = CredentialObject( - id="myservice_prod", - credential_type=CredentialType.CUSTOM, - keys={ - "api_key": CredentialKey( - name="api_key", - value=SecretStr("my-permanent-api-key") - ), - }, - provider_id="myservice", # Links to our custom provider - auto_refresh=True, -) -store.save_credential(credential) - -# The store will use MyCustomProvider for refresh/validate -token = store.get("myservice_prod") # Auto-refreshes if needed -``` - -### Example: Extending OAuth2 for a Specific Service - -```python -from core.framework.credentials.oauth2 import BaseOAuth2Provider, OAuth2Config, OAuth2Token - - -class GitHubOAuth2Provider(BaseOAuth2Provider): - """GitHub-specific OAuth2 provider with custom scopes handling.""" - - def __init__(self, client_id: str, client_secret: str): - config = OAuth2Config( - token_url="https://github.com/login/oauth/access_token", - authorization_url="https://github.com/login/oauth/authorize", - client_id=client_id, - client_secret=client_secret, - default_scopes=["repo", "read:user"], - ) - super().__init__(config) - - @property - def provider_id(self) -> str: - return "github_oauth2" - - def _parse_token_response(self, response_data: dict) -> OAuth2Token: - """GitHub returns scope as space-separated string.""" - token = super()._parse_token_response(response_data) - - # GitHub-specific: tokens don't expire unless revoked - # But we set a reasonable refresh interval - if token.expires_at is None: - token.expires_at = datetime.now(timezone.utc) + timedelta(days=30) - - return token - - def validate(self, credential: CredentialObject) -> bool: - """Validate by making a test API call to GitHub.""" - import httpx - - access_token = credential.get_key("access_token") - if not access_token: - return False - - try: - response = httpx.get( - "https://api.github.com/user", - headers={ - "Authorization": f"Bearer {access_token}", - "Accept": "application/vnd.github+json", - }, - timeout=10, - ) - return response.status_code == 200 - except httpx.HTTPError: - return False -``` - ---- - -## Testing with Credentials - -### Using the Testing Factory - -```python -from core.framework.credentials import CredentialStore - -# Create a test store with mock credentials -store = CredentialStore.for_testing({ - "brave_search": {"api_key": "test-brave-key"}, - "github_oauth": { - "access_token": "test-github-token", - "refresh_token": "test-refresh-token", - }, -}) - -# Use in tests -def test_my_tool(): - api_key = store.get("brave_search") - assert api_key == "test-brave-key" - - headers = store.resolve_headers({ - "Authorization": "Bearer {{github_oauth.access_token}}" - }) - assert headers["Authorization"] == "Bearer test-github-token" -``` - -### Using with CredentialStoreAdapter (Backward Compatible) - -```python -from aden_tools.credentials import CredentialStoreAdapter - -# For testing existing tools -credentials = CredentialStoreAdapter.for_testing({ - "brave_search": "test-key", - "openai": "test-openai-key", -}) - -# Existing API works -assert credentials.get("brave_search") == "test-key" -credentials.validate_for_tools(["web_search"]) # No error -``` - -### Mocking in Unit Tests - -```python -import pytest -from unittest.mock import MagicMock, patch - -def test_tool_with_mocked_store(): - # Create a mock store - mock_store = MagicMock() - mock_store.get.return_value = "mocked-api-key" - mock_store.resolve_headers.return_value = { - "Authorization": "Bearer mocked-token" - } - - # Inject into your tool - with patch("my_tool.credential_store", mock_store): - result = my_tool.make_api_call() - mock_store.get.assert_called_once_with("api_credential") -``` - ---- - -## Migration from CredentialManager - -If you're using the existing `CredentialManager`, migration is straightforward. - -### Option 1: Use the Adapter (No Code Changes) - -```python -# Before -from aden_tools.credentials import CredentialManager -credentials = CredentialManager() - -# After - using adapter with new store backend -from aden_tools.credentials import CredentialStoreAdapter -from core.framework.credentials import CredentialStore - -store = CredentialStore.with_encrypted_storage("~/.hive/credentials") -credentials = CredentialStoreAdapter(store) - -# All existing code works unchanged -api_key = credentials.get("brave_search") -credentials.validate_for_tools(["web_search"]) -``` - -### Option 2: Use Environment Storage (Identical Behavior) - -```python -from aden_tools.credentials import CredentialStoreAdapter - -# Creates adapter backed by environment variables -credentials = CredentialStoreAdapter.with_env_storage() - -# Behaves exactly like original CredentialManager -api_key = credentials.get("brave_search") -``` - -### Option 3: Gradual Migration - -```python -from aden_tools.credentials import CredentialStoreAdapter -from core.framework.credentials import CredentialStore, CompositeStorage, EncryptedFileStorage, EnvVarStorage - -# Use encrypted storage as primary, env vars as fallback -storage = CompositeStorage( - primary=EncryptedFileStorage("~/.hive/credentials"), - fallbacks=[EnvVarStorage({"brave_search": "BRAVE_SEARCH_API_KEY"})] -) - -store = CredentialStore(storage=storage) -credentials = CredentialStoreAdapter(store) - -# New credentials go to encrypted storage -# Old env var credentials still work as fallback -``` - ---- - -## Security Best Practices - -### 1. Use Encrypted Storage in Production - -```python -# Always use EncryptedFileStorage for production -store = CredentialStore.with_encrypted_storage("~/.hive/credentials") -``` - -### 2. Protect the Encryption Key - -```bash -# Set encryption key as environment variable -export HIVE_CREDENTIAL_KEY="your-fernet-key" - -# Or use a secrets manager -export HIVE_CREDENTIAL_KEY=$(vault kv get -field=key secret/hive/credential-key) -``` - -### 3. Use SecretStr for Values - -```python -from pydantic import SecretStr - -# SecretStr prevents accidental logging -key = CredentialKey( - name="api_key", - value=SecretStr("sensitive-value") # Won't appear in logs -) - -# Explicitly extract when needed -actual_value = key.get_secret_value() -``` - -### 4. Set Appropriate Expiration - -```python -# Always set expiration for tokens -credential.set_key( - "access_token", - token_value, - expires_at=datetime.now(timezone.utc) + timedelta(hours=1) -) -``` - -### 5. Enable Auto-Refresh - -```python -credential = CredentialObject( - id="my_oauth", - auto_refresh=True, # Automatically refresh before expiry - provider_id="oauth2", - ... -) -``` - -### 6. Validate Before Use - -```python -# Check credential validity before making API calls -if not store.is_available("api_credential"): - raise RuntimeError("Required credential not available") - -# Or use validation -errors = store.validate_for_usage("api_credential") -if errors: - raise RuntimeError(f"Credential validation failed: {errors}") -``` - -### 7. Use Template Resolution - -```python -# Don't interpolate secrets manually -# Bad: -headers = {"Authorization": f"Bearer {store.get('token')}"} - -# Good - uses template resolution which handles errors gracefully: -headers = store.resolve_headers({ - "Authorization": "Bearer {{my_oauth.access_token}}" -}) -``` - ---- - -## API Reference - -### CredentialStore - -| Method | Description | -|--------|-------------| -| `get(credential_id)` | Get default key value | -| `get_key(credential_id, key_name)` | Get specific key value | -| `get_credential(credential_id)` | Get full credential object | -| `save_credential(credential)` | Save credential to storage | -| `save_api_key(id, value)` | Convenience for simple API keys | -| `delete_credential(credential_id)` | Delete a credential | -| `is_available(credential_id)` | Check if credential exists and has value | -| `resolve(template)` | Resolve template string | -| `resolve_headers(headers)` | Resolve templates in headers dict | -| `resolve_params(params)` | Resolve templates in params dict | -| `refresh_credential(credential_id)` | Manually refresh a credential | -| `register_provider(provider)` | Register a custom provider | -| `for_testing(credentials)` | Create test store with mock data | -| `with_encrypted_storage(path)` | Create store with encrypted files | -| `with_env_storage(mapping)` | Create store with env var backend | - -### CredentialObject - -| Property/Method | Description | -|-----------------|-------------| -| `id` | Unique identifier | -| `credential_type` | Type (API_KEY, OAUTH2, etc.) | -| `keys` | Dict of CredentialKey objects | -| `get_key(name)` | Get key value by name | -| `set_key(name, value, ...)` | Set or update a key | -| `has_key(name)` | Check if key exists | -| `get_default_key()` | Get default key value | -| `needs_refresh` | True if any key is expired | -| `is_valid` | True if has valid, non-expired key | -| `auto_refresh` | Whether to auto-refresh | -| `provider_id` | ID of provider for lifecycle | - -### CredentialProvider - -| Method | Description | -|--------|-------------| -| `provider_id` | Unique identifier (property) | -| `supported_types` | List of supported CredentialTypes (property) | -| `refresh(credential)` | Refresh and return updated credential | -| `validate(credential)` | Check if credential is valid | -| `should_refresh(credential)` | Check if refresh is needed | -| `revoke(credential)` | Revoke credential (optional) | - ---- - -## Troubleshooting - -### "Unknown credential" Error - -```python -# Error: KeyError: "Unknown credential 'my_cred'" - -# Solution: Check if credential exists -if store.get_credential("my_cred") is None: - print("Credential not found - need to save it first") -``` - -### "Credential not found" in Templates - -```python -# Error: CredentialNotFoundError when resolving templates - -# Solution 1: Ensure credential is saved -store.save_api_key("my_cred", "value") - -# Solution 2: Use fail_on_missing=False -headers = store.resolve_headers(templates, fail_on_missing=False) -``` - -### Encryption Key Issues - -```python -# Error: "Failed to decrypt credential" - -# Solution: Ensure HIVE_CREDENTIAL_KEY matches what was used to encrypt -# If key is lost, credentials must be re-created -``` - -### Provider Not Found - -```python -# Warning: "No provider found for credential 'x'" - -# Solution: Register the provider or set provider_id=None for static credentials -store.register_provider(MyProvider()) - -# Or use static provider (default) -credential.provider_id = "static" # or None -``` - ---- - -## Further Reading - -- [Credential Store Design Document](credential-store-design.md) -- [OAuth2 RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) -- [Fernet Encryption](https://cryptography.io/en/latest/fernet/) diff --git a/docs/credential-system-analysis.md b/docs/credential-system-analysis.md deleted file mode 100644 index 7691ff9a05..0000000000 --- a/docs/credential-system-analysis.md +++ /dev/null @@ -1,552 +0,0 @@ -# Credential System: Complete Code Path Analysis - -## Architecture Overview - -``` - ┌──────────────┐ - │ AgentRunner │ runner.py:_validate_credentials() - └──────┬───────┘ - │ - ┌──────▼───────┐ - │ validation │ validate_agent_credentials() - │ (2-phase) │ Phase 1: presence Phase 2: health check - └──────┬───────┘ - │ - ┌─────────────▼─────────────┐ - │ CredentialStore │ store.py - │ (cache + provider mgmt) │ - └─────────────┬─────────────┘ - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - ┌──────▼──────┐ ┌──────▼──────┐ ┌───────▼───────┐ - │ EnvVarStorage│ │ Encrypted │ │ AdenCached │ - │ (primary) │ │ FileStorage │ │ Storage │ - └─────────────┘ │ (fallback) │ │ (Aden sync) │ - └─────────────┘ └───────┬───────┘ - │ - ┌───────▼───────┐ - │AdenSyncProvider│ - │+ AdenClient │ - └───────────────┘ -``` - -### Key Files - -| Layer | File | Purpose | -|-------|------|---------| -| Models | `core/framework/credentials/models.py` | `CredentialObject`, `CredentialKey`, exception hierarchy | -| Storage | `core/framework/credentials/storage.py` | `EncryptedFileStorage`, `EnvVarStorage`, `CompositeStorage` | -| Store | `core/framework/credentials/store.py` | `CredentialStore` — cache, providers, refresh | -| Validation | `core/framework/credentials/validation.py` | `validate_agent_credentials()` — two-phase pre-flight check | -| Setup | `core/framework/credentials/setup.py` | `CredentialSetupSession` — interactive credential collection | -| Aden client | `core/framework/credentials/aden/client.py` | `AdenCredentialClient` — HTTP calls to api.adenhq.com | -| Aden provider | `core/framework/credentials/aden/provider.py` | `AdenSyncProvider` — refresh, sync, fetch | -| Aden storage | `core/framework/credentials/aden/storage.py` | `AdenCachedStorage` — local cache + Aden fallback | -| Specs | `tools/src/aden_tools/credentials/` | `CredentialSpec` per integration (env_var, health check, etc.) | -| Runner | `core/framework/runner/runner.py` | `_validate_credentials()` — agent startup gate | -| TUI | `core/framework/tui/screens/credential_setup.py` | `CredentialSetupScreen` — modal credential form | -| TUI app | `core/framework/tui/app.py` | `_show_credential_setup()`, `_load_and_switch_agent()` | - -### Exception Hierarchy - -``` -CredentialError ← base (caught by runner + TUI) - ├── CredentialDecryptionError ← corrupted/wrong-key .enc files - ├── CredentialKeyNotFoundError ← key name not in credential - ├── CredentialNotFoundError ← credential ID not found - ├── CredentialRefreshError ← refresh failed (e.g., revoked OAuth) - └── CredentialValidationError ← schema/format invalid -``` - ---- - -## Scenario 1: User Supplies Correct Credential - -### Flow - -``` -AgentRunner._setup() - → _ensure_credential_key_env() # validation.py:16 - │ Loads HIVE_CREDENTIAL_KEY, ADEN_API_KEY from shell config into os.environ - │ - → _validate_credentials() # runner.py:418 - → validate_agent_credentials(nodes) # validation.py:94 - │ - │ Phase 0: Aden pre-sync (if ADEN_API_KEY set) - │ → _presync_aden_tokens() # validation.py:50 - │ → CredentialStore.with_aden_sync(auto_sync=True) - │ → For each aden_supported spec: get_key() → set os.environ - │ - │ Build store: - │ EnvVarStorage (primary) + EncryptedFileStorage (fallback if HIVE_CREDENTIAL_KEY set) - │ - │ Phase 1: Presence check - │ → store.is_available(cred_id) - │ → EnvVarStorage.load() → os.environ[env_var] → CredentialObject ✓ - │ Result: NOT in missing list - │ - │ Phase 2: Health check (if spec.health_check_endpoint set) - │ → check_credential_health(cred_name, value) - │ e.g., Anthropic: POST /v1/messages → 400 (key valid, request malformed) → valid=True - │ e.g., Brave: GET /search?q=test → 200 → valid=True - │ Result: NOT in invalid list - │ - │ errors = [] → returns normally ✓ -``` - -### What Happens - -- Validation passes silently -- Agent loads and runs -- No files written, no user-visible output -- `CredentialStore._cache` populated (5-min TTL) - ---- - -## Scenario 2: User Supplies Wrong Credential - -### Flow - -``` -validate_agent_credentials(nodes) - │ - │ Phase 1: Presence check - │ → store.is_available("anthropic") - │ → EnvVarStorage.load() → os.environ["ANTHROPIC_API_KEY"] = "wrong-key" - │ → Returns CredentialObject ✓ (value exists, content not validated) - │ Result: passes presence check, added to to_verify list - │ - │ Phase 2: Health check - │ → check_credential_health("anthropic", credential_object) - │ → AnthropicHealthChecker: POST /v1/messages with x-api-key: "wrong-key" - │ → Response: 401 Unauthorized - │ → HealthCheckResult(valid=False, message="API key is invalid") - │ → Added to invalid list, cred_name added to failed_cred_names - │ - │ CredentialError raised: - │ "Invalid or expired credentials: - │ ANTHROPIC_API_KEY for event_loop nodes — Anthropic API key is invalid - │ Get a new key at: https://console.anthropic.com/settings/keys" - │ exc.failed_cred_names = ["anthropic"] -``` - -### TUI Path (non-interactive) - -``` -_load_and_switch_agent() # app.py:356 - except CredentialError as e: # app.py:382 - → _show_credential_setup(agent_path, e) # app.py:404 - → build_setup_session_from_error(e) # validation.py:253 - → failed_cred_names = ["anthropic"] - → Creates MissingCredential for anthropic - → push_screen(CredentialSetupScreen) -``` - -### CLI Path (interactive with TTY) - -``` -_validate_credentials() # runner.py:418 - except CredentialError as e: # runner.py:440 - → print(str(e), file=sys.stderr) - → session = build_setup_session_from_error(e) - → session.run_interactive() # Terminal prompts - → validate_agent_credentials(nodes) # Re-validate -``` - -### What User Sees - -- TUI: Credential setup modal with the invalid credential's input field -- CLI: Error message printed, interactive prompts - -### Silent Failure Risk - -If `check_credential_health()` itself throws (network timeout, DNS failure, import error), -it's caught at `validation.py:231`: -```python -except Exception as exc: - logger.debug("Health check for %s failed: %s", cred_name, exc) -``` -The credential is NOT added to `invalid`. **Agent starts with a bad key.** Only `logger.debug` -records the issue. - ---- - -## Scenario 3: Credential Expired But Can Be Refreshed - -Applies to OAuth2 credentials (Google, HubSpot, etc.) managed via AdenSyncProvider. - -### Flow: Token Refresh During Runtime - -``` -CredentialStore.get_credential(cred_id, refresh_if_needed=True) # store.py:176 - │ - │ Check cache → cached credential found - │ → _should_refresh(cached) # store.py:442 - │ → AdenSyncProvider.should_refresh(credential) # provider.py:238 - │ → access_key = credential.keys["access_token"] - │ → datetime.now(UTC) >= (expires_at - 5min buffer) - │ → Returns True (within refresh window) - │ - │ → _refresh_credential(cached) # store.py:456 - │ → AdenSyncProvider.refresh(credential) # provider.py:151 - │ → client.request_refresh(credential.id) # client.py:356 - │ → POST /v1/credentials/{id}/refresh - │ → Server refreshes OAuth token, returns new access_token - │ → _update_credential_from_aden(credential, response) - │ → Updates access_token value + expires_at - │ → storage.save(refreshed) # Writes new .enc file - │ → _add_to_cache(refreshed) # Updates in-memory cache - │ → Returns refreshed credential ✓ -``` - -### Flow: Expired Token Caught During Validation - -``` -validate_agent_credentials(nodes) - │ - │ Phase 0: _presync_aden_tokens() - │ → CredentialStore.with_aden_sync(auto_sync=True) - │ → provider.sync_all() fetches fresh tokens from Aden - │ → Fresh token set in os.environ ✓ - │ - │ Phase 2: Health check with fresh token → valid=True ✓ -``` - -### What Happens - -- Refresh is transparent to the user -- New token written to `~/.hive/credentials/credentials/{id}.enc` -- In-memory cache updated -- Logged: `INFO: Refreshed credential '{id}' via Aden server` - ---- - -## Scenario 4: Credential Expired and Cannot Be Refreshed - -OAuth refresh token is revoked (user disconnected integration on hive.adenhq.com, or -the refresh token itself expired). - -### Flow: Refresh Attempt - -``` -AdenSyncProvider.refresh(credential) # provider.py:151 - → client.request_refresh(credential.id) # client.py:356 - → POST /v1/credentials/{id}/refresh - → Response: 400 {"error": "refresh_failed", - │ "requires_reauthorization": true, - │ "reauthorization_url": "https://..."} - → AdenRefreshError raised # client.py:297 - - except AdenRefreshError as e: # provider.py:186 - → logger.error("Aden refresh failed for '{id}': ...") - → raise CredentialRefreshError( - "Integration '{id}' requires re-authorization. Visit: ..." - ) -``` - -### What CredentialStore Does - -``` -CredentialStore._refresh_credential(credential) # store.py:456 - except CredentialRefreshError as e: # store.py:474 - → logger.error("Failed to refresh credential '{id}': ...") - → return credential ← RETURNS STALE/EXPIRED CREDENTIAL! -``` - -**BUG: Silent failure.** The store returns the expired credential without raising. -The caller gets an expired token. Downstream API calls fail with 401. - -### During Validation - -If validation runs health check on the expired token: -``` -check_credential_health() → 401 → valid=False -→ Added to invalid list → CredentialError raised -→ TUI shows credential setup screen -``` - -### Gap: Token Expires After Validation - -If the token expires **during agent execution** (after validation passed): -- Refresh fails silently (returns stale credential) -- Tool call gets 401 from downstream API -- LLM sees tool error, no framework-level recovery - ---- - -## Scenario 5: Credential Store File Sabotaged (Wrong Content) - -File `~/.hive/credentials/credentials/{id}.enc` replaced with valid Fernet-encrypted -content encoding wrong JSON (e.g., `{"bad": "data"}`). - -### Flow - -``` -EncryptedFileStorage.load(credential_id) # storage.py:193 - → fernet.decrypt(encrypted) # Succeeds (valid Fernet) - → json.loads(decrypted) # Succeeds (valid JSON) - → _deserialize_credential(data) # storage.py:252 - → CredentialObject.model_validate({"bad": "data"}) -``` - -### Sub-case A: Missing `id` field - -``` -CredentialObject.model_validate({"bad": "data"}) - → Pydantic ValidationError: "id - Field required" - → NOT caught by EncryptedFileStorage's try/except (only covers decrypt + json.loads) - → Propagates up uncaught -``` - -**TUI**: Caught by generic `except Exception` in `_load_and_switch_agent()` (app.py:389): -``` -self.notify("Failed to load agent: 1 validation error for CredentialObject...", severity="error") -``` -User sees generic error notification. NOT a credential setup screen. **Not actionable.** - -**CLI**: Unhandled traceback. - -### Sub-case B: Valid `id` but wrong/empty keys - -``` -CredentialObject.model_validate({"id": "my_cred", "keys": {}}) - → Valid CredentialObject with keys={} (Pydantic extra="allow", keys defaults to {}) - → store.is_available() → get_credential() returns CredentialObject - → But get() / get_key() returns None → is_available returns False - → Treated as "missing" credential -``` - -User sees credential setup screen as if the credential was never configured. -**The actual cause (sabotaged file) is hidden.** - ---- - -## Scenario 6: Credential Store File Corrupted (Binary Garbage) - -File `~/.hive/credentials/credentials/{id}.enc` contains random binary data. - -### Flow - -``` -EncryptedFileStorage.load(credential_id) # storage.py:193 - → fernet.decrypt(binary_garbage) - → Raises cryptography.fernet.InvalidToken - → Caught by except Exception: # storage.py:210 - → raise CredentialDecryptionError( - "Failed to decrypt credential '{id}': InvalidToken" - ) -``` - -### Propagation - -``` -CredentialDecryptionError (subclass of CredentialError) - → CompositeStorage.load(): NOT caught → propagates - → CredentialStore.get_credential(): NOT caught → propagates - → validate_agent_credentials() → propagates out entirely -``` - -**TUI** (app.py:382): -```python -except CredentialError as e: # CATCHES CredentialDecryptionError - self._show_credential_setup(str(agent_path), credential_error=e) -``` -Shows credential setup screen! But `CredentialDecryptionError` does NOT have -`failed_cred_names` attribute → `getattr(e, "failed_cred_names", [])` returns `[]` -→ session falls back to `from_agent_path()` detection. - -User sees credential setup screen as if credential is missing. -**Corruption is hidden.** Re-entering the credential overwrites the corrupted file. - -### CompositeStorage Bug - -If `CompositeStorage(primary=EnvVarStorage, fallbacks=[EncryptedFileStorage])` is used, -the storage tries primary first. But if `EncryptedFileStorage` is a fallback and -the .enc file is corrupted: -``` -CompositeStorage.load() - → primary (EnvVarStorage) → env var IS set → returns CredentialObject ✓ -``` -The corrupted fallback is never touched. **This case works fine.** - -But if the storage order is reversed (encrypted primary, env fallback): -``` -CompositeStorage.load() - → primary (EncryptedFileStorage) → CredentialDecryptionError - → NOT caught → propagates ← BUG: fallback never tried -``` -The exception from primary propagates BEFORE checking the fallback. -**A corrupted .enc file blocks access even when the env var has a valid value.** - ---- - -## Scenario 7: ADEN_API_KEY Set But Vendor OAuth Not Authorized - -User has valid `ADEN_API_KEY`. Agent needs HubSpot/Google. User has NOT connected -that integration on hive.adenhq.com. - -### Flow - -``` -validate_agent_credentials(nodes) - │ - │ Phase 0: _presync_aden_tokens() - │ → CredentialStore.with_aden_sync(auto_sync=True) - │ → provider.sync_all(store) - │ → client.list_integrations() # GET /v1/credentials - │ → HubSpot NOT in response (never connected) - │ → Only connected integrations synced - │ - │ → For hubspot spec: get_key("hubspot", "access_token") - │ → AdenCachedStorage.load("hubspot") - │ → _provider_index.get("hubspot") → None (not synced) - │ → _load_by_id("hubspot") - │ → local: None (not cached) - │ → aden: fetch_from_aden("hubspot") - │ → GET /v1/credentials/hubspot → 404 - │ → AdenNotFoundError caught → returns None - │ → Returns None - │ → get_key returns None - │ → os.environ["HUBSPOT_ACCESS_TOKEN"] NOT set - │ - │ Phase 1: Presence check - │ → _check_credential(hubspot_spec, "hubspot", "hubspot tools") - │ → store.is_available("hubspot") → False - │ → has_aden_key=True, aden_supported=True, direct_api_key_supported=False - │ → Goes into aden_not_connected list (NOT failed_cred_names) - │ - │ CredentialError raised: - │ "Aden integrations not connected (ADEN_API_KEY is set but OAuth tokens unavailable): - │ HUBSPOT_ACCESS_TOKEN for hubspot tools - │ Connect this integration at hive.adenhq.com first." - │ exc.failed_cred_names = [] ← empty! -``` - -### TUI Behavior - -``` -_show_credential_setup(agent_path, credential_error=e) - → build_setup_session_from_error(e) - → failed_cred_names = [] → falls back to from_agent_path() - → detect_missing_credentials_from_nodes() finds hubspot missing - → session.missing = [MissingCredential(hubspot, aden_supported=True, ...)] - → NOT empty → CredentialSetupScreen pushed -``` - -Setup screen shows ADEN_API_KEY input (already set). User clicks "Save & Continue": -``` -_save_credentials() - → ADEN_API_KEY already in env → configured += 1 - → _sync_aden_credentials() - → provider.sync_all() → hubspot still not connected → synced=0 - → Notification: "No active integrations found in Aden." - → For hubspot: store.is_available("hubspot") → False - → Notification: "hubspot (id='hubspot') not found in Aden." - → configured > 0 → dismiss(True) -``` - -TUI retries `_do_load_agent()` → validation fails again → **LOOP.** - -### What User Sees - -1. Setup screen appears, ADEN_API_KEY field shown -2. User clicks Save -3. Warning: "hubspot not found in Aden. Connect this integration at hive.adenhq.com first." -4. Screen dismisses (configured=1 from ADEN_API_KEY) -5. Agent reload fails → setup screen appears again -6. Repeat forever - -### Root Cause - -`configured += 1` fires when ADEN_API_KEY is saved, even though the actual needed -credential (hubspot OAuth token) was NOT obtained. The screen dismisses with "success" -but the agent still can't load. - ---- - -## Known Silent Failure Points - -| # | Location | What Happens | Risk | -|---|----------|-------------|------| -| 1 | `validation.py:231` | `check_credential_health()` throws → `logger.debug()` → credential treated as valid | Agent starts with bad key | -| 2 | `store.py:474-476` | `CredentialRefreshError` caught → returns stale credential | Tool calls fail with 401 at runtime | -| 3 | `store.py:706-708` | `with_aden_sync()` catches all Exception → falls back to local-only store silently | Aden sync failure invisible | -| 4 | `provider.py:312-313` | Individual integration sync fails → `logger.warning()` → skipped | Integration silently missing | -| 5 | `credential_setup.py:262-263` | `_persist_to_local_store()` → `except Exception: pass` | Credential lost on restart | -| 6 | `storage.py:489-501` | `CompositeStorage.load()` doesn't catch primary storage exceptions | Corrupted .enc blocks env var fallback | -| 7 | `validation.py:63-65` | `_presync_aden_tokens()` catches all Exception → `logger.warning()` | Aden tokens not refreshed, stale values used | - ---- - -## Storage Priority Order - -### During Validation (`validate_agent_credentials`) - -``` -1. os.environ (via EnvVarStorage) ← WINS if set -2. ~/.hive/credentials/credentials/*.enc ← fallback (only if HIVE_CREDENTIAL_KEY set) -``` - -### During Runtime (`CredentialStoreAdapter.default()`) - -``` -1. EncryptedFileStorage ← primary (if HIVE_CREDENTIAL_KEY set) -2. EnvVarStorage ← fallback -3. AdenSyncProvider ← if ADEN_API_KEY set, auto-refresh on access -``` - -**Note: validation and runtime use DIFFERENT storage priority orders.** Validation -prefers env vars; runtime prefers encrypted store. This means a credential can pass -validation (from env) but fail at runtime (encrypted store has stale value and env -var was only set in the validation process, not persisted). - -### During TUI Credential Setup (`_sync_aden_credentials`) - -``` -1. AdenSyncProvider.sync_all() ← fetches from Aden API -2. AdenCachedStorage ← local encrypted cache - (no EnvVarStorage in this path) -``` - ---- - -## File Locations on Disk - -``` -~/.hive/ - credentials/ - credentials/ # EncryptedFileStorage base - {credential_id}.enc # Fernet-encrypted JSON - key.txt # HIVE_CREDENTIAL_KEY (generated if missing) - configuration.json # Global config -``` - -### .enc File Format (decrypted) - -```json -{ - "id": "hubspot", - "credential_type": "oauth2", - "keys": { - "access_token": { - "name": "access_token", - "value": "ya29.a0ARrdaM...", - "expires_at": "2025-01-15T12:00:00+00:00" - }, - "_aden_managed": { - "name": "_aden_managed", - "value": "true" - }, - "_integration_type": { - "name": "_integration_type", - "value": "hubspot" - } - }, - "provider_id": "aden_sync", - "auto_refresh": true -} -``` - -The `_integration_type` key is used by `AdenCachedStorage._index_provider()` to map -provider names (e.g., "hubspot") to hash-based credential IDs from Aden. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 570d1235b6..475ce02492 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -8,11 +8,12 @@ This guide covers everything you need to know to develop with the Aden Agent Fra 2. [Initial Setup](#initial-setup) 3. [Project Structure](#project-structure) 4. [Building Agents](#building-agents) -5. [Testing Agents](#testing-agents) -6. [Code Style & Conventions](#code-style--conventions) -7. [Git Workflow](#git-workflow) -8. [Common Tasks](#common-tasks) -9. [Troubleshooting](#troubleshooting) +5. [Running Agents](#running-agents) +6. [Testing Agents](#testing-agents) +7. [Code Style & Conventions](#code-style--conventions) +8. [Git Workflow](#git-workflow) +9. [Common Tasks](#common-tasks) +10. [Troubleshooting](#troubleshooting) --- @@ -40,121 +41,22 @@ Aden Agent Framework is a Python-based system for building goal-driven, self-imp ## Initial Setup -### Prerequisites +See [environment-setup.md](./environment-setup.md) for the full setup guide, including Windows, Alpine Linux, and troubleshooting. -Ensure you have installed: - -- **Python 3.11+** - [Download](https://www.python.org/downloads/) (3.12 or 3.13 recommended) -- **uv** - Python package manager ([Install](https://docs.astral.sh/uv/getting-started/installation/)) -- **git** - Version control -- **Claude Code** - [Install](https://docs.anthropic.com/claude/docs/claude-code) (optional) -- **Codex CLI** - [Install](https://github.com/openai/codex) (optional) - -Verify installation: - -```bash -python --version # Should be 3.11+ -uv --version # Should be latest -git --version # Any recent version -``` - -### Step-by-Step Setup +### Quick Start ```bash -# 1. Clone the repository git clone https://github.com/adenhq/hive.git cd hive - -# 2. Run automated setup ./quickstart.sh ``` -The setup script performs these actions: - -1. Checks Python version (3.11+) -2. Installs `framework` package from `/core` (editable mode) -3. Installs `aden_tools` package from `/tools` (editable mode) -4. Prompts for a default LLM provider, including Hive LLM and OpenRouter -5. Fixes package compatibility (upgrades openai for litellm) -6. Verifies all installations - -### API Keys (Optional) - -For running agents with real LLMs: - -```bash -# Add to your shell profile (~/.bashrc, ~/.zshrc, etc.) -export ANTHROPIC_API_KEY="your-key-here" -export OPENAI_API_KEY="your-key-here" # Optional -export OPENROUTER_API_KEY="your-key-here" # Optional, for OpenRouter models -export HIVE_API_KEY="your-key-here" # Optional, for Hive LLM -export BRAVE_SEARCH_API_KEY="your-key-here" # Optional, for web search tool -``` - -Get API keys: - -- **Anthropic**: [console.anthropic.com](https://console.anthropic.com/) -- **OpenAI**: [platform.openai.com](https://platform.openai.com/) -- **OpenRouter**: [openrouter.ai/keys](https://openrouter.ai/keys) -- **Hive LLM**: [Hive Discord](https://discord.com/invite/hQdU7QDkgR) -- **Brave Search**: [brave.com/search/api](https://brave.com/search/api/) - -For OpenRouter and Hive LLM configuration snippets, see [configuration.md](./configuration.md). - -### Install Claude Code Skills - -```bash -# Install building-agents and testing-agent skills -./quickstart.sh -``` - -This sets up the MCP tools and workflows for building agents. - -### Cursor IDE Support - -MCP tools are also available in Cursor. To enable: - -1. Open Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) -2. Run `MCP: Enable` to enable MCP servers -3. Restart Cursor to load the MCP servers from `.cursor/mcp.json` -4. Open Agent chat and verify MCP tools are available - -### Codex CLI Support - -Hive supports [OpenAI Codex CLI](https://github.com/openai/codex) (v0.101.0+). - -Configuration files are tracked in git: -- `.codex/config.toml` — MCP server config - -To use Codex with Hive: -1. Run `codex` in the repo root -2. Start the configured MCP-assisted workflow - -Example: -``` -Start Codex in the repo root and use the configured MCP tools -``` - - -### Opencode Support -To enable Opencode integration: - -1. Create/Ensure `.opencode/` directory exists -2. Configure MCP servers in `.opencode/mcp.json` -3. Restart Opencode to load the MCP servers -4. Switch to the Hive agent -* **Tools:** Accesses `coder-tools` and standard `tools` via standard MCP protocols over stdio. - ### Verify Setup ```bash -# Verify package imports -uv run python -c "import framework; print('✓ framework OK')" -uv run python -c "import aden_tools; print('✓ aden_tools OK')" -uv run python -c "import litellm; print('✓ litellm OK')" - -# Run an agent (after building one with coder-tools) -PYTHONPATH=exports uv run python -m your_agent_name validate +uv run python -c "import framework; print('OK')" +uv run python -c "import aden_tools; print('OK')" +uv run python -c "import litellm; print('OK')" ``` --- @@ -181,23 +83,29 @@ hive/ # Repository root │ ├── core/ # CORE FRAMEWORK PACKAGE │ ├── framework/ # Main package code +│ │ ├── agents/ # Agent definitions and helpers │ │ ├── builder/ # Agent builder utilities │ │ ├── credentials/ # Credential management +│ │ ├── debugger/ # Debugging tools │ │ ├── graph/ # GraphExecutor - executes node graphs │ │ ├── llm/ # LLM provider integrations (Anthropic, OpenAI, OpenRouter, Hive, etc.) │ │ ├── mcp/ # MCP server integration +│ │ ├── monitoring/ # Runtime monitoring +│ │ ├── observability/ # Structured logging - human-readable and machine-parseable tracing │ │ ├── runner/ # AgentRunner - loads and runs agents -| | ├── observability/ # Structured logging - human-readable and machine-parseable tracing │ │ ├── runtime/ # Runtime environment │ │ ├── schemas/ # Data schemas +│ │ ├── server/ # HTTP API server +│ │ ├── skills/ # Skill definitions │ │ ├── storage/ # File-based persistence │ │ ├── testing/ # Testing utilities +│ │ ├── tools/ # Built-in tool implementations │ │ ├── tui/ # Terminal UI dashboard -│ │ └── __init__.py +│ │ └── utils/ # Shared utilities +│ ├── tests/ # Unit and E2E tests (including dummy agents) │ ├── pyproject.toml # Package metadata and dependencies │ ├── README.md # Framework documentation -│ ├── MCP_INTEGRATION_GUIDE.md # MCP server integration guide -│ └── docs/ # Protocol documentation +│ └── MCP_INTEGRATION_GUIDE.md # MCP server integration guide │ ├── tools/ # TOOLS PACKAGE (MCP tools) │ ├── src/ @@ -320,7 +228,11 @@ If you prefer to build agents manually: } ``` -### Running Agents +--- + +## Running Agents + +### Using the `hive` CLI ```bash # Browse and run agents interactively (Recommended) @@ -331,32 +243,34 @@ hive run exports/my_agent --input '{"ticket_content": "My login is broken", "cus # Run with TUI dashboard hive run exports/my_agent --tui - ``` -> **Using Python directly:** `PYTHONPATH=exports uv run python -m agent_name run --input '{...}'` - ---- +### CLI Command Reference -## Testing Agents +| Command | Description | +| ---------------------- | ----------------------------------------------------------------------- | +| `hive tui` | Browse agents and launch TUI dashboard | +| `hive run ` | Execute an agent (`--tui`, `--model`, `--mock`, `--quiet`, `--verbose`) | +| `hive shell [path]` | Interactive REPL (`--multi`, `--no-approve`) | +| `hive info ` | Show agent details | +| `hive validate ` | Validate agent structure | +| `hive list [dir]` | List available agents | +| `hive dispatch [dir]` | Multi-agent orchestration | -### Using Built-in Test Commands +### Using Python Directly ```bash -# Run tests for an agent -PYTHONPATH=exports uv run python -m agent_name test +PYTHONPATH=exports uv run python -m agent_name run --input '{...}' ``` -This generates and runs: +--- -- **Constraint tests** - Verify agent respects constraints -- **Success tests** - Verify agent achieves success criteria -- **Integration tests** - End-to-end workflows +## Testing Agents -### Manual Testing +### Agent Tests ```bash -# Run all tests for an agent +# Run tests for an agent PYTHONPATH=exports uv run python -m agent_name test # Run specific test type @@ -370,6 +284,32 @@ PYTHONPATH=exports uv run python -m agent_name test --parallel 4 PYTHONPATH=exports uv run python -m agent_name test --fail-fast ``` +### Framework Tests + +```bash +# Run all unit tests (core + tools) +make test + +# Run linting and format checks +make check +``` + +### Dummy Agent Tests (E2E) + +The repository includes end-to-end dummy agent tests under `core/tests/dummy_agents/` that run real LLM calls against deterministic graph structures. These are **not** part of CI — run them manually to verify the executor works with real providers. + +```bash +cd core && uv run python tests/dummy_agents/run_all.py +``` + +The script detects available LLM credentials and prompts you to pick a provider. For verbose output: + +```bash +cd core && uv run python tests/dummy_agents/run_all.py --verbose +``` + +See [environment-setup.md](./environment-setup.md#testing-with-dummy-agents) for the full list of covered agents and details. + ### Writing Custom Tests ```python @@ -542,8 +482,6 @@ chore(deps): update React to 18.2.0 --- ---- - ## Common Tasks ### Adding Python Dependencies @@ -660,30 +598,7 @@ hive run exports/my_agent --verbose --input '{"task": "..."}' ## Troubleshooting -### Port Already in Use - -```bash -# Find process using port -lsof -i :3000 -lsof -i :4000 - -# Kill process -kill -9 - -``` - -### Environment Variables Not Loading - -```bash -# Verify .env file exists at project root -cat .env - -# Or check shell environment -echo $ANTHROPIC_API_KEY - -# Create .env if needed -# Then add your API keys -``` +See [environment-setup.md](./environment-setup.md#troubleshooting) for common setup issues (module not found errors, broken installations, PEP 668, etc.). --- @@ -693,7 +608,3 @@ echo $ANTHROPIC_API_KEY - **Issues**: Search [existing issues](https://github.com/adenhq/hive/issues) - **Discord**: Join our [community](https://discord.com/invite/MXE49hrKDk) - **Code Review**: Tag a maintainer on your PR - ---- - -_Happy coding!_ 🐝 diff --git a/docs/environment-setup.md b/docs/environment-setup.md index 51a3cf87e6..d1c23cbefe 100644 --- a/docs/environment-setup.md +++ b/docs/environment-setup.md @@ -66,40 +66,6 @@ source .venv/bin/activate ./quickstart.sh ``` -## Manual Setup (Alternative) - -If you prefer to set up manually or the script fails: - -### 1. Sync Workspace Dependencies - -```bash -# From repository root - this creates a single .venv at the root -uv sync -``` - -> **Note:** The `uv sync` command uses the workspace configuration in `pyproject.toml` to install both `core` (framework) and `tools` (aden_tools) packages together. This is the recommended approach over individual `pip install -e` commands which may fail due to circular dependencies. - -### 2. Activate the Virtual Environment - -```bash -# Linux/macOS -source .venv/bin/activate - -# Windows (PowerShell) -.venv\Scripts\Activate.ps1 -``` - -### 3. Verify Installation - -```bash -uv run python -c "import framework; print('✓ framework OK')" -uv run python -c "import aden_tools; print('✓ aden_tools OK')" -uv run python -c "import litellm; print('✓ litellm OK')" -``` - -> **Windows Tip:** -> If the verification commands fail on Windows, disable "App Execution Aliases" in Windows Settings → Apps → App Execution Aliases. - ## Requirements ### Python Version @@ -119,47 +85,6 @@ uv run python -c "import litellm; print('✓ litellm OK')" We recommend using `quickstart.sh` for LLM API credential setup and the credentials UI/tooling for tool credentials. -## Running Agents - -The `hive` CLI is the primary interface for running agents: - -```bash -# Browse and run agents interactively (Recommended) -hive tui - -# Run a specific agent -hive run exports/my_agent --input '{"task": "Your input here"}' - -# Run with TUI dashboard -hive run exports/my_agent --tui -``` - -### CLI Command Reference - -| Command | Description | -| ---------------------- | ----------------------------------------------------------------------- | -| `hive tui` | Browse agents and launch TUI dashboard | -| `hive run ` | Execute an agent (`--tui`, `--model`, `--mock`, `--quiet`, `--verbose`) | -| `hive shell [path]` | Interactive REPL (`--multi`, `--no-approve`) | -| `hive info ` | Show agent details | -| `hive validate ` | Validate agent structure | -| `hive list [dir]` | List available agents | -| `hive dispatch [dir]` | Multi-agent orchestration | - -### Using Python directly (alternative) - -```bash -# From /hive/ directory -PYTHONPATH=exports uv run python -m agent_name COMMAND -``` - -Windows (PowerShell): - -```powershell -$env:PYTHONPATH="core;exports" -python -m agent_name COMMAND -``` - ## Building New Agents and Run Flow Build and run an agent using Claude Code CLI with the agent building skills: @@ -217,36 +142,6 @@ Follow the prompts to: This step establishes the core concepts and rules needed before building an agent. -### 4. Apply Agent Patterns - -``` -claude> pattern guidance -``` - -Follow the prompts to: - -1. Apply best-practice agent design patterns -2. Add pause/resume flows for multi-turn interactions -3. Improve robustness with routing, fallbacks, and retries -4. Avoid common anti-patterns during agent construction - -This step helps optimize agent design before final testing. - -### 5. Test Your Agent - -``` -claude> test workflow -``` - -Follow the prompts to: - -1. Generate test guidelines for constraints and success criteria -2. Write agent tests directly under `exports/{agent}/tests/` -3. Run goal-based evaluation tests -4. Debug failing tests and iterate on agent improvements - -This step verifies that the agent meets its goals before production use. - ## Troubleshooting ### "externally-managed-environment" error (PEP 668) @@ -484,100 +379,75 @@ hive tui hive run exports/your_agent_name --input '{"task": "..."}' ``` -## IDE Setup +## Testing with Dummy Agents -### VSCode +The repository includes a suite of dummy agents under `core/tests/dummy_agents/` for end-to-end testing against real LLM providers. These are **not** part of CI — they make real API calls and are meant to be run manually to verify the executor works correctly. -Add to `.vscode/settings.json`: +### Running the Tests -```json -{ - "python.analysis.extraPaths": [ - "${workspaceFolder}/core", - "${workspaceFolder}/exports" - ], - "python.autoComplete.extraPaths": [ - "${workspaceFolder}/core", - "${workspaceFolder}/exports" - ] -} +```bash +cd core && uv run python tests/dummy_agents/run_all.py ``` -### PyCharm +The script auto-detects available LLM credentials and prompts you to pick a provider. You need at least one of: -1. Open Project Settings → Project Structure -2. Mark `core` as Sources Root -3. Mark `exports` as Sources Root +- `ANTHROPIC_API_KEY` +- `OPENAI_API_KEY` +- `GEMINI_API_KEY` +- `ZAI_API_KEY` +- A Claude Code, Codex, or Kimi subscription -## Environment Variables - -### Required for LLM Operations +For verbose output with live LLM logs, tool calls, and node traversal details: ```bash -export ANTHROPIC_API_KEY="sk-ant-..." -export OPENROUTER_API_KEY="your-openrouter-key" # Optional -export HIVE_API_KEY="your-hive-key" # Optional +cd core && uv run python tests/dummy_agents/run_all.py --verbose ``` -Quickstart also supports selecting OpenRouter and Hive LLM interactively. See [configuration.md](./configuration.md) for the full configuration examples. +### What's Covered -### Optional Configuration +| Agent | Tests | Coverage | +| -------------- | ----- | ------------------------------------------------- | +| echo | 2 | Single-node lifecycle, basic `set_output` | +| pipeline | 4 | Multi-node traversal, `input_mapping`, conversation modes | +| branch | 3 | Conditional edges, LLM-driven routing | +| parallel_merge | 4 | Fan-out/fan-in, failure strategies | +| retry | 4 | Retry mechanics, exhaustion, `ON_FAILURE` edges | +| feedback_loop | 3 | Feedback cycles, `max_node_visits` | +| worker | 4 | Real MCP tools (`example_tool`, `get_current_time`, `save_data`/`load_data`) | -```bash -# Fernet encryption key for credential store at ~/.hive/credentials -export HIVE_CREDENTIAL_KEY="your-fernet-key" +Typical runtime is 1–3 minutes depending on provider latency. -# Agent storage location (default: /tmp) -export AGENT_STORAGE_PATH="/custom/storage" -``` - -## Opencode Setup +### Running Individual Test Files -[Opencode](https://github.com/opencode-ai/opencode) is fully supported as a coding agent. - -### Automatic Setup - -Run the quickstart script in the root directory: +You can also run a specific dummy agent test with pytest directly: ```bash -./quickstart.sh +cd core && uv run pytest tests/dummy_agents/test_echo.py -v ``` -## Codex Setup +> **Note:** Individual pytest runs require the LLM provider to be configured via the `conftest.py` fixture. The `run_all.py` script handles this automatically. -[OpenAI Codex CLI](https://github.com/openai/codex) (v0.101.0+) is supported with project-level config: - -- `.codex/config.toml` — MCP server configuration - -These files are tracked in git and available on clone. To use Codex with Hive: - -1. Run `codex` in the repo root -2. Start the configured MCP-assisted workflow +## Environment Variables -Quick verification: +### Required for LLM Operations ```bash -test -f .codex/config.toml && echo "OK: Codex config" || echo "MISSING: .codex/config.toml" -echo "OK: .codex/config.toml and MCP tools configured" +export ANTHROPIC_API_KEY="sk-ant-..." +export OPENROUTER_API_KEY="your-openrouter-key" # Optional +export HIVE_API_KEY="your-hive-key" # Optional ``` -## Additional Resources - -- **Framework Documentation:** [core/README.md](../core/README.md) -- **Tools Documentation:** [tools/README.md](../tools/README.md) -- **Example Agents:** [examples/](../examples/) -- **Agent Building Guide:** [docs/developer-guide.md](./developer-guide.md) -- **Testing Guide:** [core/README.md](../core/README.md) +Quickstart also supports selecting OpenRouter and Hive LLM interactively. See [configuration.md](./configuration.md) for the full configuration examples. -## Contributing +### Optional Configuration -When contributing agent packages: +```bash +# Fernet encryption key for credential store at ~/.hive/credentials +export HIVE_CREDENTIAL_KEY="your-fernet-key" -1. Place agents in `exports/agent_name/` -2. Follow the standard agent structure (see existing agents) -3. Include README.md with usage instructions -4. Add tests if using `test workflow` -5. Document required environment variables +# Agent storage location (default: /tmp) +export AGENT_STORAGE_PATH="/custom/storage" +``` ## Support diff --git a/docs/getting-started.md b/docs/getting-started.md index 3f9de9ba4d..954991fa08 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -17,7 +17,7 @@ The fastest way to get started: ```bash # 1. Clone the repository -git clone https://github.com/adenhq/hive.git +git clone https://github.com/aden-hive/hive.git cd hive # 2. Run automated setup @@ -31,7 +31,7 @@ uv run python -c "import framework; import aden_tools; print('✓ Setup complete ```powershell # 1. Clone the repository -git clone https://github.com/adenhq/hive.git +git clone https://github.com/aden-hive/hive.git cd hive # 2. Run automated setup @@ -240,6 +240,6 @@ pip uninstall -y framework tools ## Getting Help - **Documentation**: Check the `/docs` folder -- **Issues**: [github.com/adenhq/hive/issues](https://github.com/adenhq/hive/issues) +- **Issues**: [github.com/adenhq/hive/issues](https://github.com/aden-hive/hive/issues) - **Discord**: [discord.com/invite/MXE49hrKDk](https://discord.com/invite/MXE49hrKDk) - **Build Agents**: Use the coder-tools workflow to create agents diff --git a/docs/hive-coder-meta-agent-plan.md b/docs/hive-coder-meta-agent-plan.md deleted file mode 100644 index 2602fce409..0000000000 --- a/docs/hive-coder-meta-agent-plan.md +++ /dev/null @@ -1,110 +0,0 @@ -# Hive Coder: Meta-Agent Integration Plan - -## Problem - -The hive_coder agent currently has 7 file I/O tools (`read_file`, `write_file`, `edit_file`, `list_directory`, `search_files`, `run_command`, `undo_changes`) in `tools/coder_tools_server.py`. It can write agent packages but is **not integrated into the Hive ecosystem**: - -1. **No dynamic tool discovery** — It references a static list of hive-tools in `reference/framework_guide.md`. It can't discover what MCP tools are actually available or what parameters they accept. -2. **No runtime observability** — It can't inspect sessions, checkpoints, or logs from agents it builds. When something goes wrong, the user has to manually dig through files. -3. **No test execution** — It can't run an agent's test suite structurally (it could use `run_command` with raw pytest, but has no structured test parsing). - -## Solution - -Add 8 new tools to `coder_tools_server.py` that give hive_coder deep integration with the Hive framework. Update the system prompt to teach the LLM when and how to use these meta-agent capabilities. - ---- - -## New Tools - -### 1. Tool Discovery - -**`discover_mcp_tools(server_config_path?)`** - -Connect to any MCP server and list all available tools with full schemas. Uses `framework.runner.mcp_client.MCPClient` — the same client the runtime uses. Reads a `mcp_servers.json` file (defaults to hive-tools), connects to each server, calls `list_tools()`, returns tool names + descriptions + input schemas, then disconnects. - -This replaces the static tools reference. The LLM now discovers tools dynamically before designing an agent. - -### 2. Agent Inventory - -**`list_agents()`** - -Scan `exports/` for agent packages and `~/.hive/agents/` for runtime data. Returns agent names, descriptions (from `__init__.py`), and session counts. Gives the LLM awareness of what already exists. - -### 3-7. Session & Checkpoint Inspection - -Ported from the former `agent_builder_server.py`. Pure filesystem reads — JSON + pathlib, zero framework imports. - -| Tool | Purpose | -|------|---------| -| `list_agent_sessions(agent_name, status?, limit?)` | List sessions, filterable by status | -| `list_agent_checkpoints(agent_name, session_id)` | List checkpoints for debugging | -| `get_agent_checkpoint(agent_name, session_id, checkpoint_id?)` | Load a checkpoint's full state | - -**Key difference from the old agent-builder server:** These tools accept `agent_name` (e.g. `"deep_research_agent"`) instead of raw `agent_work_dir` paths. They resolve to `~/.hive/agents/{agent_name}/` internally. Friendlier for the LLM. - -### 8. Test Execution - -**`run_agent_tests(agent_name, test_types?, fail_fast?)`** - -Ported from the former `agent_builder_server.py`. Runs pytest on an agent's test suite, sets PYTHONPATH automatically, parses output into structured results (passed/failed/skipped counts, per-test status, failure details). - ---- - -## Files to Modify - -### `tools/coder_tools_server.py` (~400 new lines) - -Add all 8 tools after the existing `undo_changes` tool: - -``` -# ── Meta-agent: Tool discovery ──────────────────────────────── -# discover_mcp_tools() - -# ── Meta-agent: Agent inventory ─────────────────────────────── -# list_agents() - -# ── Meta-agent: Session & checkpoint inspection ─────────────── -# _resolve_hive_agent_path(), _read_session_json(), _scan_agent_sessions(), _truncate_value() -# list_agent_sessions(), list_agent_checkpoints(), get_agent_checkpoint() -# list_agent_checkpoints(), get_agent_checkpoint() - -# ── Meta-agent: Test execution ──────────────────────────────── -# run_agent_tests() -``` - -### `exports/hive_coder/nodes/__init__.py` - -- Add 8 new tool names to the `tools` list -- Rewrite system prompt "Tools Available" section with meta-agent tools -- Add "Meta-Agent Capabilities" section teaching: - - Tool discovery before designing agents - - Post-build test execution - - Debugging via session/checkpoint inspection - - Agent awareness via `list_agents()` - -### `exports/hive_coder/agent.py` - -- Update `identity_prompt` to mention dynamic tool discovery and runtime observability -- Add `dynamic-tool-discovery` constraint to the goal - -### `exports/hive_coder/reference/framework_guide.md` - -Replace static tools list with a note to use `discover_mcp_tools()` instead. - ---- - -## What's NOT in Scope (deferred to v2) - -- **Agent notifications / webhook listener** — Requires always-on listener architecture -- **`compare_agent_checkpoints`** — LLM can compare by reading two checkpoints sequentially -- **Runtime log query tools** — Available in hive-tools MCP; `run_command` can access them now - ---- - -## Verification - -1. MCP server starts with all 15 tools (7 existing + 8 new) -2. `discover_mcp_tools()` connects to hive-tools and returns real tool schemas -3. Agent validation passes (`default_agent.validate()`) -4. Session tools work against existing data in `~/.hive/agents/` -5. Smoke test: launch in TUI, ask it to discover tools diff --git a/docs/issue-local-credential-parity.md b/docs/issue-local-credential-parity.md deleted file mode 100644 index e1227079fa..0000000000 --- a/docs/issue-local-credential-parity.md +++ /dev/null @@ -1,37 +0,0 @@ -# Local API key credentials lack feature parity with Aden OAuth credentials - -## Summary - -The credential tester only surfaces accounts synced via Aden OAuth (requires `ADEN_API_KEY`). Users who authenticate services with a direct API key — Brave Search, GitHub, Exa, Google Maps, Stripe, Telegram, and many others — have no way to list, manage, or test those credentials through the same interface. - -## Problem - -Local API key credentials are completely flat today: - -- **No namespace** — one env var per service (`BRAVE_SEARCH_API_KEY`), no aliases, no multi-account support -- **No identity metadata** — no way to record who owns a key (email, username, workspace) -- **No status tracking** — no "active / failed / unknown" state -- **Not visible in credential tester** — the account picker only calls the Aden API; it silently shows nothing if `ADEN_API_KEY` is absent -- **No management surface** — no list/add/delete/validate flow for API keys - -Aden credentials have all of this: `integration_id`, alias, identity, status, health-check-on-sync, and a full listing API. - -## Affected credentials (local-only by default) - -Brave Search, Exa Search, Google Search (CSE), SerpAPI, GitHub, Google Maps, Telegram, Apollo, Stripe, Razorpay, Cal.com, BigQuery, GCP Vision, Resend, and more. - -## Expected behavior - -- Running the credential tester should surface **all** configured credentials — Aden-synced and local API keys together, in the same account picker -- Local API key accounts should support aliases (`work`, `personal`) so users can store multiple keys per service -- Identity metadata (username, email, workspace) should be extracted automatically via health check when a key is saved -- A status badge (`active` / `failed` / `unknown`) should indicate whether the key was last verified successfully -- The TUI should provide an "Add Local Credential" screen with a live health check -- The MCP `store_credential` / `list_stored_credentials` / `delete_stored_credential` tools should support aliases; a new `validate_credential` tool should allow re-checking a stored key at any time - -## Root cause (bonus bug) - -Even credentials configured with the existing `store_credential` MCP tool are invisible in the credential tester because: - -1. `_list_env_fallback_accounts()` only checked env vars — it missed credentials stored in `EncryptedFileStorage` using the old flat format (`brave_search`, no alias) -2. `_activate_local_account()` early-returned for `alias == "default"`, assuming the env var was already set — but old flat encrypted credentials are not in `os.environ` diff --git a/docs/issue-queen-bee.md b/docs/issue-queen-bee.md deleted file mode 100644 index 1ae3c224c1..0000000000 --- a/docs/issue-queen-bee.md +++ /dev/null @@ -1,75 +0,0 @@ -# Hive Queen Bee: Native agent-building agent - -## Problem - -Building a Hive agent today requires manual assembly of 7+ files (`agent.py`, `config.py`, `nodes/__init__.py`, `__init__.py`, `__main__.py`, `mcp_servers.json`, tests) with precise framework conventions — correct imports, entry_points format, conversation_mode values, STEP 1/STEP 2 prompt patterns, nullable_output_keys, and more. A single missing re-export in `__init__.py` silently breaks `AgentRunner.load()`. This is the #1 friction point for new users and a recurring source of bugs even for experienced ones. - -There is no tool that understands the framework deeply enough to produce correct agents. General-purpose coding assistants hallucinate tool names, use wrong import paths (`from core.framework...`), create too many thin nodes, forget module-level exports, and produce agents that fail validation. - -## Proposal - -Build **Hive Coder** (codename "Queen Bee") — a framework-native coding agent that lives inside the framework itself and builds complete, validated agent packages from natural language. - -### Design principles - -1. **Single-node, forever-alive** — One continuous EventLoopNode conversation handles the full lifecycle (understand, qualify, design, implement, verify, iterate). No artificial phase boundaries that destroy context. - -2. **Meta-agent capabilities** — Not just a file writer. Can discover available MCP tools at runtime, inspect sessions/checkpoints of agents it builds, run their test suites, and debug failures. - -3. **Self-verifying** — Runs three validation steps after every build: class validation (graph structure), `AgentRunner.load()` (package export contract), and pytest. Fixes its own errors up to 3 attempts. - -4. **Honest qualification** — Assesses framework fit before building. If a use case is a poor fit (needs sub-second latency, pure CRUD, massive data pipelines), says so instead of producing a bad agent. - -5. **Reference-grounded** — Ships with embedded reference docs (framework guide, file templates, anti-patterns) that it reads before writing code. No reliance on training data for framework specifics. - -### Components - -#### `hive_coder` agent (`core/framework/agents/hive_coder/`) - -| File | Purpose | -|------|---------| -| `agent.py` | Goal, single-node graph, `HiveCoderAgent` class | -| `nodes/__init__.py` | `coder` EventLoopNode with comprehensive system prompt | -| `config.py` | RuntimeConfig with `~/.hive/configuration.json` auto-detection | -| `__main__.py` | Click CLI (`run`, `tui`, `info`, `validate`, `shell`) | -| `reference/framework_guide.md` | Node types, edges, patterns, async entry points | -| `reference/file_templates.md` | Complete code templates for every agent file | -| `reference/anti_patterns.md` | 22 common mistakes with explanations | - -#### Coder Tools MCP Server (`tools/coder_tools_server.py`) - -Dedicated tool server providing: - -- **File I/O**: `read_file` (with line numbers, offset/limit), `write_file` (auto-mkdir), `edit_file` (9-strategy fuzzy matching ported from opencode), `list_directory`, `search_files` (regex) -- **Shell**: `run_command` (timeout, cwd, output truncation) -- **Git**: `undo_changes` (snapshot-based rollback) -- **Meta-agent**: `discover_mcp_tools`, `list_agents`, `list_agent_sessions`, `list_agent_checkpoints`, `get_agent_checkpoint`, `run_agent_tests` - -All file operations sandboxed to a configurable project root. - -#### Framework changes - -- `hive code` CLI command — direct launch shortcut -- `hive tui` — discovers framework agents as a source -- `AgentRuntime` — cron expression support (`croniter`) for async entry points -- `prompt_composer` — appends current datetime to system prompts -- `NodeSpec.max_node_visits` — default changed from 1 to 0 (unbounded), matching forever-alive as the standard pattern -- TUI graph view — cron display and hours in countdown -- CredentialError graceful handling in TUI launch - -## Acceptance criteria - -- [ ] `hive code` launches Hive Coder in the TUI -- [ ] `hive tui` lists framework agents alongside exports/ and examples/ -- [ ] Given "build me a research agent that searches the web and summarizes findings", Hive Coder produces a valid package in `exports/` that passes `AgentRunner.load()` -- [ ] Tool discovery works: agent calls `discover_mcp_tools()` before designing, never fabricates tool names -- [ ] Self-verification: agent runs all 3 validation steps and fixes errors before presenting -- [ ] Cron timers fire on schedule (unit tested) -- [ ] `max_node_visits=0` default does not break existing agents or tests -- [ ] Reference docs are accurate and match current framework behavior - -## Non-goals - -- Multi-agent orchestration (queen spawning worker agents at runtime) — future work -- GUI/web interface — TUI only for v1 -- Auto-publishing to a registry — agents are local packages diff --git a/docs/multi-graph-sessions.md b/docs/multi-graph-sessions.md deleted file mode 100644 index a18846181a..0000000000 --- a/docs/multi-graph-sessions.md +++ /dev/null @@ -1,288 +0,0 @@ -# Plan: Multi-Graph Sessions with Guardian Pattern - -## Context - -The target experience: hive_coder builds an agent (e.g., email automation), loads it into the same runtime session, and acts as its guardian. The email agent runs autonomously while hive_coder watches for failures. On error, hive_coder asks the user for help if they're around, attempts an autonomous fix if they're away, and escalates catastrophic failures for post-mortem. - -This requires multiple agent graphs sharing a single `AgentRuntime` session — shared memory and data, but isolated conversations. The existing runtime already has most of the primitives: `ExecutionStream` accepts its own `graph`, `trigger_type="event"` subscribes entry points to the EventBus, and `_get_primary_session_state()` bridges memory across streams. - -## Architecture Overview - -``` -AgentRuntime (shared EventBus, shared state.json, shared data/) -├── hive_coder graph -│ ├── Stream "default" → coder node (client_facing, manual) -│ └── Stream "guardian" → guardian node (event-driven, subscribes to EXECUTION_FAILED) -└── email_agent graph - └── Stream "email_agent::default" → intake node (client_facing, manual) -``` - -The guardian entry point on hive_coder fires when email_agent emits `EXECUTION_FAILED`. It receives the failure event in its input, reads shared memory for context, and decides: ask user (if present), auto-fix (if away), or escalate (if catastrophic). - -## Gap 1: Event Scoping — `graph_id` on Events - -**Problem**: EventBus events carry `stream_id` and `node_id` but no `graph_id`. The guardian needs to subscribe to events from a specific graph (email_agent), not a specific stream name. - -**Solution**: Add `graph_id: str | None = None` to `AgentEvent` and `filter_graph` to `Subscription`. - -### `core/framework/runtime/event_bus.py` -- `AgentEvent` dataclass: add `graph_id: str | None = None` field, include in `to_dict()` -- `Subscription` dataclass: add `filter_graph: str | None = None` -- `subscribe()`: accept `filter_graph` param, pass to `Subscription` -- `_matches()`: check `filter_graph` against `event.graph_id` - -### `core/framework/runtime/execution_stream.py` -- `__init__()`: accept `graph_id: str | None = None`, store as `self.graph_id` -- When emitting events via `_event_bus.publish()`: set `event.graph_id = self.graph_id` - -## Gap 2: Multi-Graph Runtime — `add_graph()` / `remove_graph()` - -**Problem**: `AgentRuntime.__init__` takes a single `GraphSpec`. We need to add/remove graphs dynamically at runtime. - -**Solution**: Keep the primary graph on `__init__`. Add methods to register secondary graphs that create their own `ExecutionStream` instances backed by a different graph. - -### `core/framework/runtime/agent_runtime.py` - -New instance state: -```python -self._graph_id: str = graph_id or "primary" # ID for the primary graph -self._graphs: dict[str, _GraphRegistration] = {} # graph_id -> registration -self._active_graph_id: str = self._graph_id # TUI focus -``` - -Where `_GraphRegistration` is a simple dataclass: -```python -@dataclass -class _GraphRegistration: - graph: GraphSpec - goal: Goal - entry_points: dict[str, EntryPointSpec] - streams: dict[str, ExecutionStream] - storage_subpath: str # relative to session root, e.g. "graphs/email_agent" - event_subscriptions: list[str] # EventBus subscription IDs - timer_tasks: list[asyncio.Task] -``` - -New methods: -- `add_graph(graph_id, graph, goal, entry_points, storage_subpath=None)` — creates streams for the graph using graph-scoped storage, sets up event/timer triggers, stamps `graph_id` on all streams. Can be called while running. -- `remove_graph(graph_id)` — stops streams, cancels timers, unsubscribes events, removes registration. Cannot remove primary graph. -- `list_graphs() -> list[str]` — returns all graph IDs -- `active_graph_id` property with setter — TUI uses this to control which graph's events are displayed - -Update existing methods: -- `start()`: stamp `self._graph_id` on primary graph streams (via `ExecutionStream.graph_id`) -- `inject_input(node_id, content)`: search active graph's streams first, then all others -- `_get_primary_session_state()`: search across ALL graphs' streams (not just primary's) -- `stop()`: stop all secondary graph streams/timers/subscriptions too - -### Storage Layout -``` -~/.hive/agents/hive_coder/sessions/{session_id}/ - state.json ← SHARED across all graphs - data/ ← SHARED data directory - conversations/coder/ ← hive_coder conversations - graphs/ - email_agent/ ← secondary graph storage root - conversations/ - intake/ - checkpoints/ -``` - -Secondary graph executors get `storage_path = {session_root}/graphs/{graph_id}/` while `state.json` and `data/` remain at the session root. The `resume_session_id` mechanism in `_get_primary_session_state()` already handles this — secondary executions find the primary session's `state.json`. - -**Concurrent state.json writes**: For the guardian pattern (sequential: email_agent fails → guardian triggers), no file lock needed. But since both could technically write concurrently, add a simple `fcntl.flock()` wrapper around `_write_progress()` in the executor. Small, defensive change. - -## Gap 3: Guardian Pattern — User Presence + Autonomous Recovery - -**Problem**: When email_agent fails, hive_coder's guardian entry point must decide: ask user or auto-fix. - -**Solution**: User presence is a runtime-level signal. The guardian's system prompt and event data give it enough context to decide. - -### User Presence Tracking -Add to `AgentRuntime`: -```python -self._last_user_input_time: float = 0.0 # monotonic timestamp -``` - -Updated in `inject_input()` (called whenever user types in TUI). Exposed as: -```python -@property -def user_idle_seconds(self) -> float: - if self._last_user_input_time == 0: - return float('inf') - return time.monotonic() - self._last_user_input_time -``` - -The guardian node's system prompt instructs the LLM: "If user_idle_seconds < 120, ask the user for guidance via the client-facing interaction. If user is away, attempt an autonomous fix." - -This is NOT framework logic — it's prompt-driven. The guardian node is a regular `event_loop` node with `client_facing=True` and tools for code editing + agent lifecycle. The LLM decides the strategy based on presence info injected as context. - -### Escalation Model -Escalation = save a structured log entry. No special framework support needed. The guardian node uses `save_data("escalation_log.jsonl", ...)` via the existing data tools. The LLM writes: -```json -{"timestamp": "...", "severity": "catastrophic", "agent": "email_agent", "error": "...", "attempted_fixes": [...], "recommended_action": "..."} -``` - -Post-mortem: user opens `/data escalation_log.jsonl` or the TUI shows a notification linking to it. - -## Gap 4: Graph Lifecycle Tools — Stop/Reload/Restart - -**Problem**: hive_coder needs to programmatically stop a broken agent, fix its code, reload it, and restart it. - -**Solution**: MCP tools accessible to the active agent. Uses `ContextVar` to access the runtime (same pattern as `data_dir`). - -### `core/framework/tools/session_graph_tools.py` (NEW) - -```python -async def load_agent(agent_path: str) -> str: - """Load an agent graph into the running session.""" - -async def unload_agent(graph_id: str) -> str: - """Stop and remove an agent graph from the session.""" - -async def start_agent(graph_id: str, entry_point: str = "default", input_data: str = "{}") -> str: - """Trigger an entry point on a loaded agent graph.""" - -async def restart_agent(graph_id: str) -> str: - """Unload and re-load an agent (picks up code changes).""" - -async def list_agents() -> str: - """List all agent graphs in the current session with their status.""" - -async def get_user_presence() -> str: - """Return user idle time and presence status.""" -``` - -These tools call `runtime.add_graph()`, `runtime.remove_graph()`, `runtime.trigger()`, etc. - -### Registration -These tools are registered via `ToolRegistry` with `CONTEXT_PARAM` for `runtime` (injected by the executor, same as `data_dir`). Only available when the runtime is multi-graph capable (set by `cmd_code()`). - -## Gap 5: TUI Integration — Graph Switching + Background Notifications - -### `core/framework/tui/app.py` -- `_route_event()`: check `event.graph_id` against `runtime.active_graph_id` - - Events from active graph: route normally (streaming, chat, etc.) - - `CLIENT_INPUT_REQUESTED` from background graph: show notification bar - - `EXECUTION_FAILED` from background graph: show error notification - - `EXECUTION_COMPLETED` from background: show brief completion notice - - Other background events: silent (visible in logs) -- `action_switch_graph(graph_id)`: update `runtime.active_graph_id`, refresh graph view, show header - -### `core/framework/tui/widgets/chat_repl.py` -- Track `_input_graph_id: str | None` alongside `_input_node_id` -- `handle_input_requested(node_id, graph_id)`: if background graph, show notification instead of enabling input -- `_submit_input()`: pass `graph_id` to help `inject_input()` route correctly -- New TUI commands: - - `/graphs` — list loaded graphs and their status - - `/graph ` — switch active graph focus - - `/load ` — load an agent graph into the session - - `/unload ` — remove a graph from the session -- On graph switch: flush streaming state, render graph header separator - -### `core/framework/tui/widgets/graph_view.py` -- `switch_graph(graph_id)` — re-render the graph visualization for the new active graph -- When multi-graph active: show tab-like header listing all loaded graphs - -## Gap 6: CLI + Runner Integration - -### `core/framework/runner/cli.py` -- `cmd_code()` creates the hive_coder runtime with `graph_id="hive_coder"` -- Registers `session_graph_tools` with the tool config so hive_coder's LLM can call them -- Sets `runtime._multi_graph_capable = True` flag - -### `core/framework/runner/runner.py` -- New method: `setup_as_secondary(runtime, graph_id)` — configures this runner to join an existing `AgentRuntime` as a secondary graph. Uses the existing `AgentRunner.load()` to parse agent.json, then calls `runtime.add_graph()` with the parsed graph/goal/entry_points. - -## Gap 7: Reliable Mid-Node Resume - -**Problem**: When an EventLoopNode is interrupted (crash, Ctrl+Z, context switch), resume doesn't restore to exactly where execution stopped. Several pieces of in-node state are lost, which changes behavior post-resume. In multi-graph sessions with parallel execution and frequent context switching, these gaps compound. - -### What's already restored correctly -- **Conversation history**: All messages persisted to disk immediately via `FileConversationStore._persist()` — one file per message in `parts/NNNNNNNNNN.json` -- **OutputAccumulator values**: Write-through to `cursor.json` on every `accumulator.set()` call -- **Iteration counter**: Written to `cursor.json` at the end of each iteration (step 6g) -- **Orphaned tool calls**: `_repair_orphaned_tool_calls()` patches in-flight tool calls with error messages so the LLM knows to retry - -### What's lost — and fixes - -#### 1. `user_interaction_count` (CRITICAL) -Resets to 0 on resume. This controls client-facing blocking semantics: before the first interaction, `set_output`-only turns don't prevent blocking (the LLM must present to the user first). After resume, a node that had 3 user interactions behaves as if the user never interacted. - -**Fix**: Persist `user_interaction_count` to `cursor.json` alongside `iteration` and `outputs`. Write it in `_write_cursor()` (step 6g), restore in `_restore()`. - -**Files**: `core/framework/graph/event_loop_node.py` - -#### 2. Accumulator outputs not in SharedMemory -The `OutputAccumulator` writes to `cursor.json` (durable) but only writes to `SharedMemory` when the judge ACCEPTs. On crash, the CancelledError handler captures `memory.read_all()` — which doesn't include the accumulator's WIP values. On resume, edge conditions checking those memory keys see `None`. - -**Fix**: In the executor's `CancelledError` handler, read the interrupted node's `cursor.json` and write any accumulator outputs to `memory` before building `session_state_out`. This ensures resume memory includes WIP output values. - -**Files**: `core/framework/graph/executor.py` (CancelledError handler, ~line 1289) - -#### 3. Stall/doom-loop detection counters -`recent_responses` and `recent_tool_fingerprints` reset to empty lists. A previously near-stalled node gets a fresh detection budget. - -**Fix**: Persist these to `cursor.json`. They're small (last N strings). Write in `_write_cursor()`, restore in `_restore()`. - -**Files**: `core/framework/graph/event_loop_node.py` - -#### 4. `continuous_conversation` at executor level -In continuous mode, the executor's `continuous_conversation` variable is `None` on resume. The node's `_restore()` recovers messages from disk, but the executor doesn't pre-populate this variable until the node returns. - -**Fix**: After a resumed node completes, set `continuous_conversation = result.conversation` (this already happens in the normal path at line 1155 — verify it also runs on the resume path). - -**Files**: `core/framework/graph/executor.py` - -### Multi-graph specific: independent resume per graph -Each graph in a multi-graph session has its own storage subdirectory (`graphs/{graph_id}/`) with its own `conversations/`, `checkpoints/`, and `cursor.json` files. Resume is already per-executor, so each graph resumes independently. The shared `state.json` at the session root captures the union of all graphs' memory — the `fcntl.flock()` wrapper on `_write_progress()` (Gap 2) ensures concurrent writes don't corrupt it. - -### Implementation -These fixes are prerequisite to multi-graph and should be done as **Phase 0** before the EventBus changes: -1. Persist `user_interaction_count` + stall/doom counters to `cursor.json` -2. Restore them in `_restore()` -3. Flush accumulator outputs to SharedMemory in executor's CancelledError handler -4. Verify continuous_conversation is set on resume path - -## Implementation Phases - -### Phase 0: Reliable Mid-Node Resume (prerequisite) -1. `event_loop_node.py` — persist `user_interaction_count`, `recent_responses`, `recent_tool_fingerprints` to `cursor.json` via `_write_cursor()`; restore in `_restore()` -2. `executor.py` — in CancelledError handler, read interrupted node's `cursor.json` accumulator outputs and write to `memory` before building `session_state_out` -3. `executor.py` — verify `continuous_conversation` is populated on resume path - -### Phase 1: EventBus Foundation -1. `event_bus.py` — `graph_id` on `AgentEvent`, `filter_graph` on `Subscription` + `_matches()` -2. `execution_stream.py` — accept and stamp `graph_id` on emitted events - -### Phase 2: Multi-Graph Runtime -3. `agent_runtime.py` — `_GraphRegistration` dataclass, `add_graph()`, `remove_graph()`, `list_graphs()`, `active_graph_id` property -4. `agent_runtime.py` — update `inject_input()`, `_get_primary_session_state()`, `stop()` for multi-graph -5. `agent_runtime.py` — user presence tracking (`_last_user_input_time`, `user_idle_seconds`) -6. Storage path logic: secondary graphs get `{session_root}/graphs/{graph_id}/` - -### Phase 3: Graph Lifecycle Tools -7. `core/framework/tools/session_graph_tools.py` — `load_agent`, `unload_agent`, `start_agent`, `restart_agent`, `list_agents`, `get_user_presence` -8. `runner.py` — `setup_as_secondary()` method - -### Phase 4: TUI Integration -9. `app.py` — `graph_id` event filtering, background notifications, `action_switch_graph` -10. `chat_repl.py` — `/graphs`, `/graph`, `/load`, `/unload` commands, graph_id tracking -11. `graph_view.py` — multi-graph header, `switch_graph()` - -### Phase 5: hive_coder Integration -12. `cli.py` — `cmd_code()` sets up multi-graph capable runtime, registers graph tools -13. hive_coder's agent config — add guardian entry point with `trigger_type="event"` subscribing to `EXECUTION_FAILED` -14. Guardian node system prompt — presence-aware triage logic (ask user / auto-fix / escalate) - -## Backward Compatibility -- Single-graph `hive run exports/my_agent` unchanged: `graph_id` defaults to `None`, no secondary graphs loaded, events carry `graph_id=None`, TUI shows no graph switching UI -- All new fields are optional with `None` defaults -- `_get_primary_session_state()` existing behavior preserved when no secondary graphs exist - -## Verification -1. **Unit**: `add_graph()` creates streams with correct `graph_id`, events carry `graph_id`, `filter_graph` works in subscriptions, `inject_input()` routes to correct graph -2. **Integration**: Load hive_coder + email_agent, email_agent fails → guardian fires → reads shared memory → decides action -3. **TUI**: `/graphs` shows both, `/graph` switches, background failure notification appears, input routing works across graphs -4. **Backward compat**: `hive run exports/deep_research_agent --tui` works unchanged -5. **Lifecycle**: `restart_agent` picks up code changes, `unload_agent` cleans up streams and subscriptions diff --git a/docs/quizzes/00-job-post.md b/docs/quizzes/00-job-post.md deleted file mode 100644 index 35a4fbce2c..0000000000 --- a/docs/quizzes/00-job-post.md +++ /dev/null @@ -1,157 +0,0 @@ -# 🚀 Software Development Engineer - -**Location:** San Francisco, CA (Hybrid) or Remote -**Type:** Full-time -**Team:** Engineering - ---- - -## About Aden - -We're building the future of AI agents. Aden is an open-source framework for creating self-improving, production-ready AI agents with built-in cost controls, human-in-the-loop capabilities, and comprehensive observability. - -Our mission: Make AI agents reliable enough for real-world production use. - ---- - -## The Role - -We're looking for a Software Development Engineer to help build and scale our AI agent platform. You'll work across the full stack, from our React dashboard to our Node.js backend, contributing to core infrastructure that powers autonomous AI systems. - -This is an opportunity to work on cutting-edge AI infrastructure alongside a small, experienced team passionate about shipping great software. - ---- - -## What You'll Do - -- Build and maintain features across our full-stack TypeScript codebase -- Design and implement APIs for agent management, monitoring, and control -- Work with real-time systems (WebSockets, event streaming) -- Optimize database performance (TimescaleDB, MongoDB, Redis) -- Contribute to our Model Context Protocol (MCP) server and tooling -- Collaborate on architecture decisions for scalability and reliability -- Write clean, tested, well-documented code -- Participate in code reviews and help maintain code quality - ---- - -## Tech Stack - -**Frontend (Honeycomb Dashboard)** -- React 18 + TypeScript -- Vite -- Tailwind CSS + Radix UI -- Zustand (state management) -- TanStack Query -- Recharts + Vega (data visualization) -- Socket.io (real-time updates) - -**Backend (Hive)** -- Node.js + Express + TypeScript -- Socket.io (WebSocket) -- Model Context Protocol (MCP) -- Zod (validation) -- Passport + JWT (authentication) - -**Data Layer** -- TimescaleDB (time-series metrics) -- MongoDB (policies, configuration) -- Redis (caching, pub/sub) - -**Infrastructure** -- Docker + Docker Compose -- Kubernetes + Kustomize -- GitHub Actions (CI/CD) -- Nginx - ---- - -## What We're Looking For - -**Required:** -- 2+ years of professional software development experience -- Strong proficiency in TypeScript and Node.js -- Experience with React and modern frontend development -- Familiarity with SQL and NoSQL databases -- Understanding of RESTful APIs and WebSocket communication -- Comfortable with Git and collaborative development workflows -- Strong problem-solving skills and attention to detail - -**Nice to Have:** -- Experience with AI/LLM applications or agent frameworks -- Knowledge of time-series databases (TimescaleDB, InfluxDB) -- Kubernetes and container orchestration experience -- Experience with real-time systems at scale -- Contributions to open-source projects -- Familiarity with Model Context Protocol (MCP) - ---- - -## What We Offer - -- Competitive salary + equity -- Health, dental, and vision insurance -- Flexible work arrangements (hybrid/remote) -- Learning & development budget -- Home office setup stipend -- Opportunity to work on open-source AI infrastructure -- Small team, big impact - ---- - -## How to Apply - -**Show us what you can do by contributing to our open-source project:** - -1. **Solve an existing issue** - - Browse our [GitHub Issues](https://github.com/adenhq/hive/issues) - - Look for issues labeled `good first issue` or `help wanted` - - Comment on the issue to claim it - - Submit a Pull Request with your solution - -2. **Create new issues** - - Found a bug? Report it with clear reproduction steps - - Have an idea? Open a feature request with your proposal - - Spotted documentation gaps? Suggest improvements - - Quality issues that show you understand the codebase stand out - -3. **Submit Pull Requests** - - Fix bugs, add features, or improve documentation - - Follow our contribution guidelines - - Write clear PR descriptions explaining your changes - - Respond to code review feedback - -4. **Submit your application:** - - Email: `contact@adenhq.com` - - Subject: `[SDE] Your Name` - - Include: - - Resume/CV - - GitHub profile - - Links to your Issues and/or PRs on our repo - - Brief intro about yourself - -5. **What happens next:** - - We review your contributions (1-2 weeks) - - Technical interview (60 min) - - Team interview (45 min) - - Offer 🎉 - ---- - -## Why Join Us? - -- **Impact:** Your code will power AI agents used by developers worldwide -- **Open Source:** Everything we build is open source -- **Learning:** Work with cutting-edge AI and distributed systems -- **Culture:** Small team, low ego, high trust, ship fast -- **Growth:** Early-stage company with room to grow - ---- - -*Aden is an equal opportunity employer. We celebrate diversity and are committed to creating an inclusive environment for all employees.* - ---- - -**Questions?** Email us at `contact@adenhq.com` or open an issue on [GitHub](https://github.com/adenhq/hive). - -Made with 🔥 Passion in San Francisco diff --git a/docs/quizzes/01-getting-started.md b/docs/quizzes/01-getting-started.md deleted file mode 100644 index 3575b22f43..0000000000 --- a/docs/quizzes/01-getting-started.md +++ /dev/null @@ -1,165 +0,0 @@ -# 🚀 Getting Started Challenge - -Welcome to Aden! This challenge will help you get familiar with our project and community. Complete all tasks to earn your first badge! - -**Difficulty:** Beginner -**Time:** ~30 minutes -**Prerequisites:** GitHub account - ---- - -## Part 1: Join the Aden Community (10 points) - -### Task 1.1: Star the Repository ⭐ -Show your support by starring our repo! - -1. Go to [github.com/adenhq/hive](https://github.com/adenhq/hive) -2. Click the **Star** button in the top right -3. **Screenshot** your starred repo (showing the star count) - -### Task 1.2: Watch the Repository 👁️ -Stay updated with our latest changes! - -1. Click the **Watch** button -2. Select **"All Activity"** to get notifications -3. **Screenshot** your watch settings - -### Task 1.3: Fork the Repository 🍴 -Create your own copy to experiment with! - -1. Click the **Fork** button -2. Keep the default settings and create the fork -3. **Screenshot** your forked repository - -### Task 1.4: Join Discord 💬 -Connect with our community! - -1. Join our [Discord server](https://discord.com/invite/MXE49hrKDk) -2. Introduce yourself in `#introductions` -3. **Screenshot** your introduction message - ---- - -## Part 2: Explore Aden (15 points) - -### Task 2.1: README Scavenger Hunt 🔍 -Find the answers to these questions by reading our README: - -1. What are the **three LLM providers** Aden supports out of the box? -2. How many **MCP tools** does the Hive Control Plane provide? -3. What is the name of the **frontend dashboard**? -4. In the "How It Works" section, what is **Step 5**? -5. What city is Aden made with passion in? - -### Task 2.2: Architecture Quiz 🏗️ -Based on the architecture diagram in the README: - -1. What are the three databases in the Storage Layer? -2. Name two components inside an "SDK-Wrapped Node" -3. What connects the Control Plane to the Dashboard? -4. Where does "Failure Data" flow to in the diagram? - -### Task 2.3: Comparison Challenge 📊 -From the Comparison Table, answer: - -1. What category is CrewAI in? -2. What's the Aden difference compared to LangChain? -3. Which framework focuses on "emergent behavior in large-scale simulations"? - ---- - -## Part 3: Quick Code Exploration (15 points) - -### Task 3.1: Project Structure 📁 -Clone your fork and explore the codebase: - -```bash -git clone https://github.com/YOUR_USERNAME/hive.git -cd hive -``` - -Answer these questions: - -1. What is the main frontend folder called? -2. What is the main backend folder called? -3. What file would you edit to configure the application? -4. What's the command to set up the Python environment (hint: check README)? - -### Task 3.2: Find the Features 🎯 -Look through the codebase to find: - -1. Where are the MCP tools defined? (provide the file path) -2. What port does the MCP server run on? (hint: check the tools/Dockerfile) -3. Find one TypeScript interface related to agents (provide file path and interface name) - ---- - -## Part 4: Creative Challenge (10 points) - -### Task 4.1: Agent Idea 💡 -Aden can build self-improving agents for any use case. Propose ONE creative agent idea: - -1. **Name:** Give your agent a catchy name -2. **Goal:** What problem does it solve? (2-3 sentences) -3. **Self-Improvement:** How would it get better over time when things fail? -4. **Human-in-the-Loop:** When would it need human input? - -Example format: -``` -Name: DocBot -Goal: Automatically keeps documentation in sync with code changes. - Monitors PRs and updates relevant docs. -Self-Improvement: When docs get rejected in review, it learns the feedback - and adjusts its writing style and coverage. -Human-in-the-Loop: Major architectural changes require human approval - before doc updates go live. -``` - ---- - -## Submission Checklist - -Before submitting, make sure you have: - -- [ ] Screenshots from Part 1 (Star, Watch, Fork, Discord) -- [ ] Answers to all Part 2 questions -- [ ] Answers to all Part 3 questions -- [ ] Your creative agent idea from Part 4 - -### How to Submit - -1. Create a GitHub Gist at [gist.github.com](https://gist.github.com) -2. Name it `aden-getting-started-YOURNAME.md` -3. Include all your answers and screenshots (use image hosting like imgur for screenshots) -4. Email the Gist link to `careers@adenhq.com` - - Subject: `[Getting Started Challenge] Your Name` - - Include your GitHub username - ---- - -## Scoring - -| Section | Points | -|---------|--------| -| Part 1: Community | 10 | -| Part 2: Explore | 15 | -| Part 3: Code | 15 | -| Part 4: Creative | 10 | -| **Total** | **50** | - -**Passing score:** 40+ points - ---- - -## What's Next? - -After completing this challenge, choose your specialization: - -- **Backend Engineers:** [🧠 Architecture Deep Dive](./02-architecture-deep-dive.md) -- **AI/ML Engineers:** [🤖 Build Your First Agent](./03-build-your-first-agent.md) -- **Frontend Engineers:** [🎨 Frontend Challenge](./04-frontend-challenge.md) -- **DevOps Engineers:** [🔧 DevOps Challenge](./05-devops-challenge.md) - ---- - -Good luck! We're excited to see your submissions! 🎉 diff --git a/docs/quizzes/02-architecture-deep-dive.md b/docs/quizzes/02-architecture-deep-dive.md deleted file mode 100644 index aff0856edb..0000000000 --- a/docs/quizzes/02-architecture-deep-dive.md +++ /dev/null @@ -1,195 +0,0 @@ -# 🧠 Architecture Deep Dive Challenge - -Test your understanding of Aden's architecture and backend systems. This challenge is perfect for backend engineers who want to contribute to the core framework. - -**Difficulty:** Intermediate -**Time:** 1-2 hours -**Prerequisites:** Complete [Getting Started](./01-getting-started.md), familiarity with Node.js/TypeScript - ---- - -## Part 1: System Architecture (20 points) - -### Task 1.1: Component Mapping 🗺️ -Study the Aden architecture and answer: - -1. Describe the data flow from when a user defines a goal to when worker agents execute. Include all major components. - -2. Explain the "self-improvement loop" - what happens when an agent fails? - -3. What's the difference between: - - Coding Agent vs Worker Agent - - STM (Short-Term Memory) vs LTM (Long-Term Memory) - - Hot storage vs Cold storage for events - -### Task 1.2: Database Design 💾 -Aden uses three databases. For each, explain: - -1. **TimescaleDB:** What type of data is stored? Why TimescaleDB specifically? -2. **MongoDB:** What is stored here? Why a document database? -3. **PostgreSQL:** What is its primary purpose? - -### Task 1.3: Real-time Communication 📡 -Answer these about the real-time systems: - -1. What protocol connects the SDK to the Hive backend for policy updates? -2. How does the dashboard receive live agent metrics? -3. What is the heartbeat interval for SDK health checks? - ---- - -## Part 2: Code Analysis (25 points) - -### Task 2.1: API Routes 🛣️ -Explore the backend code and document: - -1. List all the main API route prefixes (e.g., `/user`, `/v1/control`, etc.) -2. For the `/v1/control` routes, what are the main endpoints and their purposes? -3. What authentication method is used for API requests? - -### Task 2.2: MCP Tools Deep Dive 🔧 -The MCP server provides 19 tools. Categorize them and answer: - -1. List all **Budget tools** (tools with "budget" in the name) -2. List all **Analytics tools** -3. List all **Policy tools** -4. Pick ONE tool and explain: - - What parameters does it accept? - - What does it return? - - When would the Coding Agent use it? - -### Task 2.3: Event Specification 📊 -Find and analyze the SDK event specification: - -1. What are the four event types that can be sent from SDK to server? -2. For a `MetricEvent`, list at least 5 fields that are captured -3. What is "Layer 0 content capture" and when is it used? - ---- - -## Part 3: Design Questions (25 points) - -### Task 3.1: Scaling Scenario 📈 -Imagine Aden needs to handle 1000 concurrent agents across 50 teams: - -1. Which components would be the bottleneck? Why? -2. How would you horizontally scale the system? -3. What database optimizations would you recommend? -4. How would you ensure team data isolation at scale? - -### Task 3.2: New Feature Design 🆕 -Design a new feature: **Agent Collaboration Logs** - -Requirements: -- Track when agents communicate with each other -- Store the message content and metadata -- Support querying by time range, agent, or conversation thread -- Real-time streaming to the dashboard - -Provide: -1. Database schema design (which DB and table structure) -2. API endpoint design (routes and payloads) -3. How would this integrate with existing event batching? - -### Task 3.3: Failure Handling ⚠️ -The self-healing loop is core to Aden. Design the detailed flow: - -1. How should failures be categorized (types of failures)? -2. What data should be captured for the Coding Agent to improve? -3. How do you prevent infinite failure loops? -4. When should the system escalate to human intervention? - ---- - -## Part 4: Practical Implementation (30 points) - -### Task 4.1: Write a New MCP Tool 🛠️ -Create a new MCP tool called `hive_agent_performance_report`: - -**Requirements:** -- Returns performance metrics for a specific agent over a time period -- Includes: total requests, success rate, avg latency, total cost -- Accepts parameters: `agent_id`, `start_time`, `end_time` - -Provide: -1. Tool definition (name, description, input schema) -2. Implementation pseudocode or actual TypeScript -3. Example request and response - -### Task 4.2: Budget Enforcement Algorithm 💰 -Implement the logic for budget enforcement: - -```typescript -interface BudgetCheck { - action: 'allow' | 'block' | 'throttle' | 'degrade'; - reason: string; - degradedModel?: string; - delayMs?: number; -} - -function checkBudget( - currentSpend: number, - budgetLimit: number, - requestedModel: string, - estimatedCost: number -): BudgetCheck { - // Your implementation here -} -``` - -Requirements: -- Block if budget would be exceeded -- Throttle (2000ms delay) if ≥95% used -- Degrade to cheaper model if ≥80% used -- Allow otherwise - -### Task 4.3: Event Aggregation Query 📈 -Write a SQL query for TimescaleDB that: - -1. Aggregates metrics by hour for the last 24 hours -2. Groups by model and provider -3. Calculates: total tokens, total cost, avg latency, request count -4. Orders by total cost descending - ---- - -## Submission Checklist - -- [ ] All Part 1 architecture answers -- [ ] All Part 2 code analysis answers -- [ ] All Part 3 design documents -- [ ] All Part 4 implementations - -### How to Submit - -1. Create a GitHub Gist with your answers -2. Name it `aden-architecture-YOURNAME.md` -3. Include any code files as separate files in the Gist -4. Email to `careers@adenhq.com` - - Subject: `[Architecture Challenge] Your Name` - ---- - -## Scoring - -| Section | Points | -|---------|--------| -| Part 1: System Architecture | 20 | -| Part 2: Code Analysis | 25 | -| Part 3: Design Questions | 25 | -| Part 4: Implementation | 30 | -| **Total** | **100** | - -**Passing score:** 75+ points - ---- - -## Bonus Points (+20) - -- Identify a bug or improvement in the actual codebase and open an issue -- Submit a PR fixing a documentation issue -- Create a diagram of your design using Mermaid or similar - ---- - -Good luck! We're looking for engineers who can think systematically about distributed systems! 🏗️ diff --git a/docs/quizzes/03-build-your-first-agent.md b/docs/quizzes/03-build-your-first-agent.md deleted file mode 100644 index 2cfec45d37..0000000000 --- a/docs/quizzes/03-build-your-first-agent.md +++ /dev/null @@ -1,277 +0,0 @@ -# 🤖 Build Your First Agent Challenge - -Get hands-on with AI agents! This challenge is for AI/ML engineers who want to understand agent development and contribute to Aden's agent ecosystem. - -**Difficulty:** Intermediate -**Time:** 2-3 hours -**Prerequisites:** Complete [Getting Started](./01-getting-started.md), Python experience, basic LLM knowledge - ---- - -## Part 1: Agent Fundamentals (20 points) - -### Task 1.1: Core Concepts 📚 -Answer these questions about Aden's agent architecture: - -1. What is a "node" in Aden's architecture? How does it differ from a traditional function? - -2. Explain the SDK-wrapped node concept. What four capabilities does every node get automatically? - -3. What's the difference between: - - A Coding Agent and a Worker Agent - - Goal-driven vs workflow-driven development - - Predefined edges vs dynamic connections - -4. Why does Aden generate "connection code" instead of using a fixed graph structure? - -### Task 1.2: Memory Systems 🧠 -Aden has sophisticated memory management: - -1. Describe the three types of memory available to agents: - - Shared Memory - - STM (Short-Term Memory) - - LTM (Long-Term Memory / RLM) - -2. When would an agent use each type? - -3. How does "Session Local memory isolation" work? - -### Task 1.3: Human-in-the-Loop 🙋 -Explain the HITL system: - -1. What triggers a human intervention point? -2. What happens if a human doesn't respond within the timeout? -3. List three scenarios where HITL would be essential - ---- - -## Part 2: Agent Design (25 points) - -### Task 2.1: Design a Multi-Agent System 🎭 -Design a **Content Marketing Agent System** with multiple worker agents: - -**Goal:** Automatically create and publish blog posts based on company news - -Requirements: -- Must use at least 3 specialized worker agents -- Include human approval before publishing -- Handle failures gracefully - -Provide: -1. **Agent Diagram:** Show all agents and how they connect -2. **Agent Descriptions:** For each agent, describe: - - Name and role - - Inputs and outputs - - Tools it needs - - Failure scenarios -3. **Human Checkpoints:** Where would humans intervene? -4. **Self-Improvement:** How would this system learn from failures? - -### Task 2.2: Goal Definition 🎯 -Write a natural language goal that a user might give to create your system: - -``` -Example Goal: -"Create a system that monitors our company RSS feed for news, -writes engaging blog posts about each news item, gets approval -from the marketing team, and publishes to our WordPress site. -If a post is rejected, learn from the feedback to write better -posts in the future." -``` - -Your goal should be: -- Clear and specific -- Include success criteria -- Mention failure handling -- Specify human touchpoints - -### Task 2.3: Test Cases 📋 -Design 5 test cases for your agent system: - -| Test Case | Input | Expected Output | Success Criteria | -|-----------|-------|-----------------|------------------| -| Happy Path | Normal news item | Published blog post | Post live on site | -| ... | ... | ... | ... | - -Include at least: -- 1 happy path -- 2 edge cases -- 2 failure scenarios - ---- - -## Part 3: Practical Implementation (30 points) - -### Task 3.1: Agent Pseudocode 💻 -Write pseudocode for ONE of your worker agents: - -```python -class ContentWriterAgent: - """ - Agent that takes news items and writes blog posts. - """ - - def __init__(self, config): - # Initialize with tools, memory, LLM access - pass - - async def execute(self, input_data): - # Main execution logic - pass - - async def handle_failure(self, error, context): - # How to handle different types of failures - pass - - async def learn_from_feedback(self, feedback): - # How to improve based on rejection feedback - pass -``` - -Provide detailed pseudocode with: -- LLM calls and prompts -- Memory reads/writes -- Tool usage -- Error handling - -### Task 3.2: Prompt Engineering 📝 -Write the actual prompts for your agent: - -1. **System Prompt:** The core instructions for your agent -2. **Task Prompt Template:** How tasks are presented to the agent -3. **Feedback Learning Prompt:** How rejection feedback is processed - -Example format: -``` -SYSTEM PROMPT: -You are a professional content writer for {company_name}... - -TASK PROMPT: -Given the following news item: -{news_content} - -Write a blog post that... - -FEEDBACK PROMPT: -Your previous post was rejected with this feedback: -{feedback} - -Analyze what went wrong and... -``` - -### Task 3.3: Tool Definitions 🔧 -Define the tools your agent needs: - -```python -tools = [ - { - "name": "search_company_knowledge", - "description": "Search internal knowledge base for relevant context", - "parameters": { - "query": "string - search query", - "limit": "int - max results (default 5)" - }, - "returns": "List of relevant documents" - }, - # Add more tools... -] -``` - -Define at least 3 tools with: -- Clear name and description -- Input parameters with types -- Return value description -- Example usage - ---- - -## Part 4: Advanced Challenges (25 points) - -### Task 4.1: Failure Evolution Design 🔄 -Design the self-improvement mechanism in detail: - -1. **Failure Classification:** Create a taxonomy of failures for your agent - ``` - - LLM Failures: rate limit, content filter, hallucination - - Tool Failures: API down, invalid response, timeout - - Logic Failures: wrong output format, missing data - - Human Rejection: quality issues, off-brand, factual error - ``` - -2. **Learning Storage:** What data do you store for each failure type? - -3. **Evolution Strategy:** How does the Coding Agent use failure data to improve? - -4. **Guardrails:** What prevents the system from making things worse? - -### Task 4.2: Cost Optimization 💰 -Your agent system will be called frequently. Design cost optimizations: - -1. **Model Selection:** When to use GPT-4 vs GPT-3.5 vs Claude Haiku? -2. **Caching Strategy:** What can be cached to reduce LLM calls? -3. **Batching:** How can you batch operations for efficiency? -4. **Budget Rules:** Design budget rules for your system - -### Task 4.3: Observability Dashboard 📊 -Design what metrics should be tracked for your agent system: - -1. **Performance Metrics:** (at least 5) -2. **Quality Metrics:** (at least 3) -3. **Cost Metrics:** (at least 3) -4. **Alert Conditions:** When should the system alert humans? - ---- - -## Submission Checklist - -- [ ] All Part 1 concept answers -- [ ] Complete multi-agent design (Part 2) -- [ ] Implementation code/pseudocode (Part 3) -- [ ] Advanced challenge solutions (Part 4) - -### How to Submit - -1. Create a GitHub Gist with your answers -2. Name it `aden-agent-challenge-YOURNAME.md` -3. Include code files separately -4. If you created diagrams, include images -5. Email to `careers@adenhq.com` - - Subject: `[Agent Challenge] Your Name` - ---- - -## Scoring - -| Section | Points | -|---------|--------| -| Part 1: Fundamentals | 20 | -| Part 2: Design | 25 | -| Part 3: Implementation | 30 | -| Part 4: Advanced | 25 | -| **Total** | **100** | - -**Passing score:** 75+ points - ---- - -## Bonus Points (+25) - -- **+10:** Actually implement a working prototype using any framework -- **+10:** Create a demo video of your agent in action -- **+5:** Submit a PR adding your agent as a template to the repo - ---- - -## Example Agent Templates - -Need inspiration? Here are some agent ideas: - -1. **Research Agent:** Gathers information from multiple sources -2. **Code Review Agent:** Reviews PRs and suggests improvements -3. **Customer Support Agent:** Handles support tickets with escalation -4. **Data Pipeline Agent:** Monitors and fixes data quality issues -5. **Meeting Agent:** Summarizes meetings and creates action items - ---- - -Good luck! We're excited to see your creative agent designs! 🤖✨ diff --git a/docs/quizzes/04-frontend-challenge.md b/docs/quizzes/04-frontend-challenge.md deleted file mode 100644 index 16d932346b..0000000000 --- a/docs/quizzes/04-frontend-challenge.md +++ /dev/null @@ -1,277 +0,0 @@ -# 🎨 Frontend Challenge - -Build beautiful, functional interfaces for AI agent management! This challenge is for frontend engineers who want to contribute to Honeycomb, Aden's dashboard. - -**Difficulty:** Intermediate -**Time:** 1-2 hours -**Prerequisites:** Complete [Getting Started](./01-getting-started.md), React/TypeScript experience - ---- - -## Part 1: Codebase Exploration (15 points) - -### Task 1.1: Tech Stack Analysis 🔍 -Explore the `honeycomb/` directory and answer: - -1. What React version is used? -2. What styling solution is used? (Tailwind, CSS Modules, etc.) -3. What state management approach is used? -4. What charting library is used for analytics? -5. How does the frontend communicate with the backend in real-time? - -### Task 1.2: Component Structure 📁 -Map out the component architecture: - -1. List the main page components (routes) -2. Find and describe 3 reusable components -3. Where are TypeScript types defined for agent data? -4. How is authentication handled in the frontend? - -### Task 1.3: Design System 🎨 -Analyze the UI patterns: - -1. What UI component library is used? (Radix, shadcn, etc.) -2. Find 3 custom components that aren't from a library -3. What color scheme/theme approach is used? -4. How are loading and error states typically handled? - ---- - -## Part 2: UI/UX Analysis (20 points) - -### Task 2.1: Dashboard Critique 📊 -Based on the codebase and agent control types, analyze what the dashboard likely shows: - -1. What key metrics would you display for agent monitoring? -2. How would you visualize the agent graph/connections? -3. What real-time updates are most important to show? -4. Critique: What could be improved in the current approach? - -### Task 2.2: User Flow Design 🔄 -Design the user flow for this feature: - -**Feature:** "Create New Agent from Goal" - -Map out: -1. Entry point (where does the user start?) -2. Step-by-step screens needed -3. Form fields and validation -4. Success/error states -5. How to show agent generation progress - -Provide a wireframe (can be ASCII, hand-drawn, or Figma): - -``` -+----------------------------------+ -| Create New Agent | -|----------------------------------| -| Step 1: Define Your Goal | -| +----------------------------+ | -| | Describe what you want | | -| | your agent to achieve... | | -| +----------------------------+ | -| | -| [ ] Include human checkpoints | -| [ ] Enable cost controls | -| | -| [Cancel] [Next Step] | -+----------------------------------+ -``` - -### Task 2.3: Accessibility Audit ♿ -Consider accessibility for the agent dashboard: - -1. List 5 accessibility requirements for a data-heavy dashboard -2. How would you make real-time updates accessible? -3. What keyboard navigation is essential? -4. How would you handle screen readers for the agent graph visualization? - ---- - -## Part 3: Implementation Challenges (35 points) - -### Task 3.1: Build a Component 🧱 -Create a React component: `AgentStatusCard` - -Requirements: -- Display agent name, status, and key metrics -- Status: online (green), degraded (yellow), offline (red), unknown (gray) -- Show: requests/min, success rate, avg latency, cost today -- Include a mini sparkline chart for requests over last hour -- Expandable to show more details -- TypeScript with proper types - -```tsx -interface AgentStatusCardProps { - agent: { - id: string; - name: string; - status: 'online' | 'degraded' | 'offline' | 'unknown'; - metrics: { - requestsPerMinute: number; - successRate: number; - avgLatency: number; - costToday: number; - requestHistory: number[]; // last 60 minutes - }; - }; - onExpand?: () => void; - expanded?: boolean; -} - -export function AgentStatusCard({ agent, onExpand, expanded }: AgentStatusCardProps) { - // Your implementation -} -``` - -### Task 3.2: Real-time Hook 🔌 -Create a custom hook for real-time agent metrics: - -```tsx -interface UseAgentMetricsOptions { - agentId: string; - refreshInterval?: number; -} - -interface UseAgentMetricsResult { - metrics: AgentMetrics | null; - isLoading: boolean; - error: Error | null; - lastUpdated: Date | null; -} - -function useAgentMetrics(options: UseAgentMetricsOptions): UseAgentMetricsResult { - // Your implementation - // Should handle: - // - WebSocket subscription for real-time updates - // - Fallback to polling if WebSocket unavailable - // - Cleanup on unmount - // - Error handling and retry logic -} -``` - -### Task 3.3: Data Visualization 📈 -Design and implement a cost breakdown chart component: - -Requirements: -- Show cost by model (GPT-4, Claude, etc.) as a donut/pie chart -- Show cost over time as a line/area chart -- Toggle between daily/weekly/monthly views -- Animate transitions between views -- Show tooltip with details on hover - -Provide: -1. Component interface/props -2. Implementation (can use Recharts, Vega, or any library) -3. Example mock data -4. Responsive design considerations - ---- - -## Part 4: Advanced Frontend (30 points) - -### Task 4.1: Agent Graph Visualization 🕸️ -Design how to visualize the agent graph: - -**Challenge:** Show a dynamic graph where: -- Nodes are agents -- Edges are connections between agents -- Real-time data flows are animated -- Users can zoom, pan, and click for details - -Provide: -1. Library choice and justification (D3, React Flow, Cytoscape, etc.) -2. Component architecture -3. Performance considerations for 50+ nodes -4. Interaction design (how users explore the graph) -5. Code sketch for the main component - -### Task 4.2: Optimistic UI for Budget Controls 💰 -Implement optimistic UI for budget updates: - -**Scenario:** User changes an agent's budget limit -- Update should appear instantly -- Backend validation may reject the change -- Must handle race conditions with real-time updates - -Provide: -1. State management approach -2. Rollback mechanism on failure -3. Conflict resolution strategy -4. User feedback design - -```tsx -function useBudgetUpdate(agentId: string) { - // Your implementation showing: - // - Optimistic update - // - Server sync - // - Rollback on error - // - Conflict handling -} -``` - -### Task 4.3: Performance Optimization ⚡ -The dashboard shows data for 100+ agents with real-time updates. - -Design optimizations for: - -1. **Rendering:** How to prevent unnecessary re-renders? -2. **Data:** How to handle high-frequency WebSocket updates? -3. **Memory:** How to prevent memory leaks with subscriptions? -4. **Initial Load:** How to prioritize visible content? - -Provide specific techniques and code examples for each. - ---- - -## Submission Checklist - -- [ ] All Part 1 exploration answers -- [ ] Part 2 wireframes and design analysis -- [ ] Part 3 component implementations -- [ ] Part 4 advanced designs - -### How to Submit - -1. Create a GitHub Gist with your answers -2. Name it `aden-frontend-YOURNAME.md` -3. Include code files as separate Gist files -4. If you created working code, include a CodeSandbox/StackBlitz link -5. Email to `careers@adenhq.com` - - Subject: `[Frontend Challenge] Your Name` - ---- - -## Scoring - -| Section | Points | -|---------|--------| -| Part 1: Exploration | 15 | -| Part 2: UI/UX | 20 | -| Part 3: Implementation | 35 | -| Part 4: Advanced | 30 | -| **Total** | **100** | - -**Passing score:** 75+ points - ---- - -## Bonus Points (+20) - -- **+10:** Create a working prototype in CodeSandbox -- **+5:** Submit a PR improving existing UI -- **+5:** Create a Figma design for a new feature - ---- - -## Resources - -- [React Documentation](https://react.dev) -- [Tailwind CSS](https://tailwindcss.com) -- [Radix UI](https://radix-ui.com) -- [Recharts](https://recharts.org) -- [React Flow](https://reactflow.dev) (for graph visualization) - ---- - -Good luck! We love engineers who care about user experience! 🎨✨ diff --git a/docs/quizzes/05-devops-challenge.md b/docs/quizzes/05-devops-challenge.md deleted file mode 100644 index 1cbf34ef99..0000000000 --- a/docs/quizzes/05-devops-challenge.md +++ /dev/null @@ -1,309 +0,0 @@ -# 🔧 DevOps Challenge - -Master the deployment and operations of AI agent infrastructure! This challenge is for DevOps and Platform engineers who want to ensure Aden runs reliably at scale. - -**Difficulty:** Advanced -**Time:** 2-3 hours -**Prerequisites:** Complete [Getting Started](./01-getting-started.md), Docker, Linux, CI/CD experience - ---- - -## Part 1: Infrastructure Analysis (20 points) - -### Task 1.1: Docker Deep Dive 🐳 -Analyze the Aden Docker setup: - -1. What Dockerfile exists in the repository and what does it build? -2. How would you containerize the MCP tools server? -3. How is hot reload enabled for development? -4. What would need to be mounted as volumes for persistence? -5. What networking considerations exist for the MCP server? - -### Task 1.2: Service Dependencies 🔗 -Map the service dependencies: - -1. Create a dependency diagram showing which services depend on which -2. What's the startup order? Does it matter? -3. What happens if MongoDB is unavailable? -4. What happens if Redis is unavailable? -5. Which services are stateless vs stateful? - -### Task 1.3: Configuration Management ⚙️ -Analyze how configuration works: - -1. How does `config.yaml` get generated? -2. What environment variables are required? -3. How are secrets managed? (API keys, database passwords) -4. What's the difference between dev and prod configs? - ---- - -## Part 2: Deployment Scenarios (25 points) - -### Task 2.1: Production Deployment Plan 📋 -Design a production deployment for a company with: -- 100 active agents -- 10,000 LLM requests/day -- 99.9% uptime requirement -- Multi-region support needed - -Provide: -1. **Infrastructure diagram** (cloud provider of your choice) -2. **Service sizing** (CPU, memory for each component) -3. **Database setup** (primary/replica, backups) -4. **Load balancing strategy** -5. **Estimated monthly cost** - -### Task 2.2: Kubernetes Migration 🚢 -Convert the Docker Compose setup to Kubernetes: - -1. Create a Kubernetes deployment manifest for the Hive backend -2. Create a Service and Ingress for external access -3. Design a ConfigMap for configuration -4. Create a Secret for sensitive data -5. Set up a HorizontalPodAutoscaler - -```yaml -# Provide your manifests here -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hive-backend -spec: - # Your implementation -``` - -### Task 2.3: High Availability Design 🔄 -Design for high availability: - -1. How would you handle backend service failures? -2. How would you handle database failover? -3. What's your strategy for zero-downtime deployments? -4. How would you handle WebSocket connections during rolling updates? -5. Design a disaster recovery plan - ---- - -## Part 3: CI/CD Pipeline (25 points) - -### Task 3.1: GitHub Actions Pipeline 🔄 -Create a complete CI/CD pipeline: - -```yaml -# .github/workflows/ci-cd.yml -name: Aden CI/CD - -on: - push: - branches: [main, develop] - pull_request: - branches: [main] - -jobs: - # Your implementation should include: - # - Linting - # - Type checking - # - Unit tests - # - Integration tests - # - Build Docker images - # - Push to registry - # - Deploy to staging (on develop) - # - Deploy to production (on main, with approval) -``` - -Include: -1. Separate jobs for frontend and backend -2. Matrix testing for multiple Node versions -3. Docker layer caching -4. Deployment gates/approvals -5. Rollback strategy - -### Task 3.2: Testing Strategy 🧪 -Design the testing infrastructure: - -1. **Unit Tests:** What to test? How to mock LLM calls? -2. **Integration Tests:** How to test with real databases? -3. **E2E Tests:** What user flows to test? -4. **Load Tests:** How to simulate agent traffic? -5. **Chaos Tests:** What failures to simulate? - -Provide example test configurations for each type. - -### Task 3.3: Environment Management 🌍 -Design environment strategy: - -| Environment | Purpose | Data | Who Can Access | -|-------------|---------|------|----------------| -| Local | Development | Mock | Developers | -| Dev | Integration | Sanitized | Engineering | -| Staging | Pre-prod | Copy of prod | Engineering + QA | -| Production | Live | Real | Restricted | - -For each environment, specify: -1. How it's provisioned -2. How data is managed -3. How deployments happen -4. Access control - ---- - -## Part 4: Observability & Operations (30 points) - -### Task 4.1: Monitoring Stack 📊 -Design a comprehensive monitoring solution: - -1. **Metrics:** What to collect? (list at least 10 key metrics) -2. **Logs:** Logging strategy and aggregation -3. **Traces:** Distributed tracing for agent flows -4. **Dashboards:** Design 3 key dashboards - -```yaml -# Provide a docker-compose addition for monitoring -services: - prometheus: - # Your config - grafana: - # Your config - # Add more as needed -``` - -### Task 4.2: Alerting Rules 🚨 -Create alerting rules for critical scenarios: - -```yaml -# Prometheus alerting rules -groups: - - name: aden-critical - rules: - - alert: HighErrorRate - expr: # Your expression - for: 5m - labels: - severity: critical - annotations: - summary: "High error rate detected" - description: # Your description - - # Add more alerts for: - # - Service down - # - High latency - # - Budget exceeded - # - Database connection issues - # - Memory pressure -``` - -Create at least 8 alert rules covering different failure modes. - -### Task 4.3: Incident Response 🆘 -Create an incident response runbook: - -**Scenario:** Agent response times spike to 30 seconds (normal: 2 seconds) - -Provide: -1. **Detection:** How was this discovered? -2. **Triage:** Initial investigation steps -3. **Diagnosis:** Decision tree for root causes -4. **Resolution:** Steps for each root cause -5. **Post-mortem:** Template for incident review - -```markdown -# Runbook: High Agent Latency - -## Symptoms -- Agent response times > 10s -- Dashboard showing degraded status - -## Initial Triage -1. Check [ ] Is this affecting all agents or specific ones? -2. Check [ ] Is the backend healthy? (health endpoint) -3. Check [ ] Are databases responsive? -... - -## Diagnostic Steps -... - -## Resolution Steps -### If LLM Provider Issue: -... - -### If Database Issue: -... -``` - ---- - -## Part 5: Security Hardening (Bonus - 20 points) - -### Task 5.1: Security Audit 🔒 -Perform a security analysis: - -1. **Network:** What ports are exposed? Are they necessary? -2. **Secrets:** How are secrets currently handled? Improvements? -3. **Authentication:** How is API auth implemented? -4. **Container Security:** What image scanning would you add? -5. **Database Security:** What hardening is needed? - -### Task 5.2: Compliance Checklist ✅ -For SOC 2 compliance, what changes are needed? - -1. Access control improvements -2. Audit logging requirements -3. Encryption requirements -4. Data retention policies -5. Incident response requirements - ---- - -## Submission Checklist - -- [ ] Part 1 infrastructure analysis -- [ ] Part 2 deployment designs and manifests -- [ ] Part 3 CI/CD pipeline YAML -- [ ] Part 4 monitoring and alerting configs -- [ ] (Bonus) Part 5 security analysis - -### How to Submit - -1. Create a GitHub Gist with your answers -2. Name it `aden-devops-YOURNAME.md` -3. Include all YAML/configuration files -4. Include any diagrams (use Mermaid, ASCII, or image links) -5. Email to `careers@adenhq.com` - - Subject: `[DevOps Challenge] Your Name` - ---- - -## Scoring - -| Section | Points | -|---------|--------| -| Part 1: Infrastructure | 20 | -| Part 2: Deployment | 25 | -| Part 3: CI/CD | 25 | -| Part 4: Observability | 30 | -| Part 5: Security (Bonus) | +20 | -| **Total** | **100 (+20)** | - -**Passing score:** 75+ points - ---- - -## Bonus Points (+15) - -- **+5:** Set up a working local Kubernetes cluster with Aden -- **+5:** Create a Terraform module for cloud deployment -- **+5:** Submit a PR improving deployment documentation - ---- - -## Resources - -- [Docker Documentation](https://docs.docker.com) -- [Kubernetes Documentation](https://kubernetes.io/docs) -- [GitHub Actions](https://docs.github.com/en/actions) -- [Prometheus](https://prometheus.io/docs) -- [Grafana](https://grafana.com/docs) - ---- - -Good luck! We're looking for engineers who keep systems running smoothly! 🔧✨ diff --git a/docs/quizzes/README.md b/docs/quizzes/README.md deleted file mode 100644 index de81d2e603..0000000000 --- a/docs/quizzes/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Aden Engineering Challenges - -Welcome to the Aden Engineering Challenges! These quizzes are designed for students and applicants who want to join the Aden team or contribute to our open-source projects. - ---- - -## 💼 We're Hiring! - -**[Software Development Engineer](./00-job-post.md)** - Full-stack TypeScript, React, Node.js, AI agents - ---- - -## How It Works - -1. **Choose your track** based on your interests and skill level -2. **Complete the challenges** in order -3. **Submit your work** as instructed in each challenge -4. **Get noticed** by the Aden team! - -## Available Tracks - -| Track | Difficulty | Time Estimate | Best For | -|-------|------------|---------------|----------| -| [🚀 Getting Started](./01-getting-started.md) | Beginner | 30 min | Everyone - Start Here! | -| [🧠 Architecture Deep Dive](./02-architecture-deep-dive.md) | Intermediate | 1-2 hours | Backend Engineers | -| [🤖 Build Your First Agent](./03-build-your-first-agent.md) | Intermediate | 2-3 hours | AI/ML Engineers | -| [🎨 Frontend Challenge](./04-frontend-challenge.md) | Intermediate | 1-2 hours | Frontend Engineers | -| [🔧 DevOps Challenge](./05-devops-challenge.md) | Advanced | 2-3 hours | DevOps/Platform Engineers | - -## Why Complete These Challenges? - -- 📚 **Learn** about cutting-edge AI agent technology -- 🏆 **Stand out** in your application to Aden -- 🤝 **Connect** with the Aden engineering team -- 🌟 **Contribute** to an exciting open-source project -- 💼 **Showcase** your skills with real-world projects - -## Submission Guidelines - -After completing challenges, submit your work by: - -1. Creating a GitHub Gist with your answers -2. Emailing the link to `contact@adenhq.com` with subject: `[Engineering Challenge] Your Name - Track Name` -3. Include your GitHub username in the email - -## Getting Help - -- Join our [Discord](https://discord.com/invite/MXE49hrKDk) and ask in #applicant-challenges -- Check out the [documentation](https://docs.adenhq.com/) -- Review the [README](../../README.md) for project overview - ---- - -**Ready to begin?** Start with [🚀 Getting Started](./01-getting-started.md)! diff --git a/docs/server-cli-arch.md b/docs/server-cli-arch.md deleted file mode 100644 index 02c9e098f8..0000000000 --- a/docs/server-cli-arch.md +++ /dev/null @@ -1,594 +0,0 @@ -# Server & CLI Architecture: Shared Runtime Primitives - -## Executive Summary - -The `hive serve` HTTP server and the CLI commands (`hive run`, `hive shell`, `hive tui`) are two access layers built on top of the **same runtime primitives**. There is no separate "server runtime" — the HTTP server is a thin REST/SSE translation layer that delegates every operation to the same `AgentRunner`, `AgentRuntime`, `GraphExecutor`, and storage subsystems that the CLI uses directly. - ---- - -## Architecture Overview - -```mermaid -flowchart TB - subgraph Access["Access Layer"] - direction LR - subgraph CLI["CLI Access"] - Run["hive run"] - Shell["hive shell"] - TUI["hive tui"] - end - subgraph HTTP["HTTP Access (hive serve)"] - REST["REST Endpoints
(aiohttp routes)"] - SSE["SSE Event Stream"] - SPA["Frontend SPA"] - end - end - - subgraph Bridge["Server Bridge Layer"] - AM["AgentManager
Multi-agent slot lifecycle"] - end - - subgraph Core["Shared Runtime Core"] - AR["AgentRunner
Load, validate, run agents"] - ART["AgentRuntime
Multi-entry-point orchestration"] - GE["GraphExecutor
Node execution, edge traversal"] - end - - subgraph Storage["Shared Storage"] - SS["SessionStore"] - CS["CheckpointStore"] - RL["RuntimeLogger
L1/L2/L3 logs"] - SM["SharedMemory"] - end - - Run --> AR - Shell --> AR - TUI --> AR - REST --> AM - SSE --> AM - AM --> AR - AR --> ART - ART --> GE - GE --> SS - GE --> CS - GE --> RL - GE --> SM -``` - -### Key Insight - -The only component unique to the HTTP server is `AgentManager` — a thin lifecycle wrapper that holds multiple `AgentSlot` instances concurrently. Each slot contains the **exact same objects** the CLI creates: - -```python -@dataclass -class AgentSlot: - id: str - agent_path: Path - runner: AgentRunner # Same as CLI - runtime: AgentRuntime # Same as CLI - info: AgentInfo # Same as CLI - loaded_at: float -``` - ---- - -## The Shared Runtime Stack - -### Layer 1: AgentRunner - -The entry point for loading and running any agent, regardless of access mode. - -```python -# CLI usage (hive run) -runner = AgentRunner.load("exports/my-agent", model="claude-sonnet-4-6") -result = await runner.run(input_data={"query": "hello"}) - -# Server usage (identical call inside AgentManager.load_agent) -runner = AgentRunner.load(agent_path, model=model, interactive=False) -``` - -**Responsibilities:** -- Load agents from `agent.json` or `agent.py` -- Discover tools from `tools.py` and `mcp_servers.json` -- Validate credentials before execution -- Provide `AgentInfo` and `ValidationResult` inspection - -### Layer 2: AgentRuntime - -The orchestrator for concurrent, multi-entry-point execution. - -```python -# Both CLI (TUI/shell) and server use the same runtime -runtime = runner._agent_runtime -await runtime.start() - -# Triggering execution — identical call in both modes -exec_id = await runtime.trigger("default", {"query": "hello"}) - -# Injecting user input — identical call in both modes -await runtime.inject_input(node_id="chat", content="user message") - -# Subscribing to events — CLI uses for TUI, server uses for SSE -sub_id = runtime.subscribe_to_events([EventType.CLIENT_OUTPUT_DELTA], handler) -``` - -### Layer 3: GraphExecutor - -Executes the agent graph node-by-node. Completely unaware of whether it was invoked from CLI or HTTP. - -**Responsibilities:** -- Node execution following `GraphSpec` edges -- Edge condition evaluation and routing -- `SharedMemory` management across nodes -- Checkpoint creation for resumability -- HITL pause points at `client_facing` nodes - -### Layer 4: Storage - -All storage subsystems are shared — sessions, checkpoints, and logs written via CLI are readable via the HTTP server and vice versa. - -``` -~/.hive/agents/{agent_name}/ -├── sessions/ # SessionStore -│ └── session_YYYYMMDD_HHMMSS_{uuid}/ -│ ├── state.json # Session state -│ ├── conversations/ # Per-node EventLoop state -│ ├── artifacts/ # Large outputs -│ └── logs/ # L1/L2/L3 observability -│ ├── summary.json -│ ├── details.jsonl -│ └── tool_logs.jsonl -├── runtime_logs/ # RuntimeLogger -└── artifacts/ # Fallback storage -``` - ---- - -## HTTP Endpoint to Runtime Primitive Mapping - -Every HTTP endpoint is a direct, thin delegation to a shared runtime method. No execution logic lives in the route handlers. - -### Agent Lifecycle - -| HTTP Endpoint | Method | Runtime Primitive | -|---|---|---| -| `POST /api/agents` | Load agent | `AgentRunner.load()` → `runtime.start()` | -| `DELETE /api/agents/{id}` | Unload agent | `runner.cleanup_async()` | -| `GET /api/agents/{id}` | Agent info | `runner.info()` → `AgentInfo` | -| `GET /api/agents/{id}/stats` | Statistics | Runtime metrics collection | -| `GET /api/agents/{id}/entry-points` | Entry points | `runtime.get_entry_points()` | -| `GET /api/agents/{id}/graphs` | List graphs | `runtime.list_graphs()` | -| `GET /api/discover` | Discover agents | Filesystem scan (same as `hive list`) | - -### Execution Control - -| HTTP Endpoint | Method | Runtime Primitive | -|---|---|---| -| `POST /api/agents/{id}/trigger` | Start execution | `runtime.trigger(entry_point_id, input_data)` | -| `POST /api/agents/{id}/chat` | Auto-route | `runtime.inject_input()` or `runtime.trigger()` | -| `POST /api/agents/{id}/inject` | Send user input | `runtime.inject_input(node_id, content)` | -| `POST /api/agents/{id}/resume` | Resume session | `runtime.trigger()` with `session_state` | -| `POST /api/agents/{id}/stop` | Pause execution | Cancels the execution task | -| `POST /api/agents/{id}/replay` | Replay checkpoint | Checkpoint restore → `runtime.trigger()` | -| `GET /api/agents/{id}/goal-progress` | Goal progress | `runtime.get_goal_progress()` | - -### Event Streaming - -| HTTP Endpoint | Method | Runtime Primitive | -|---|---|---| -| `GET /api/agents/{id}/events` | SSE stream | `runtime.subscribe_to_events()` | - -Default event types streamed: `CLIENT_OUTPUT_DELTA`, `CLIENT_INPUT_REQUESTED`, `LLM_TEXT_DELTA`, `TOOL_CALL_STARTED`, `TOOL_CALL_COMPLETED`, `EXECUTION_STARTED`, `EXECUTION_COMPLETED`, `EXECUTION_FAILED`, `EXECUTION_PAUSED`, `NODE_LOOP_STARTED`, `NODE_LOOP_COMPLETED`, `EDGE_TRAVERSED`, `GOAL_PROGRESS`. - -### Session Management - -| HTTP Endpoint | Method | Runtime Primitive | -|---|---|---| -| `GET /api/agents/{id}/sessions` | List sessions | `SessionStore.list_sessions()` | -| `GET /api/agents/{id}/sessions/{sid}` | Session details | `SessionStore.read_state()` | -| `DELETE /api/agents/{id}/sessions/{sid}` | Delete session | `SessionStore.delete_session()` | -| `GET /api/agents/{id}/sessions/{sid}/checkpoints` | List checkpoints | `CheckpointStore.list_checkpoints()` | -| `POST /api/agents/{id}/sessions/{sid}/checkpoints/{cid}/restore` | Restore checkpoint | Checkpoint load → `runtime.trigger()` | -| `GET /api/agents/{id}/sessions/{sid}/messages` | Chat history | `ConversationStore` reads | - -### Graph Inspection - -| HTTP Endpoint | Method | Runtime Primitive | -|---|---|---| -| `GET /api/agents/{id}/graphs/{gid}/nodes` | List nodes | `GraphSpec` inspection | -| `GET /api/agents/{id}/graphs/{gid}/nodes/{nid}` | Node details | `GraphSpec` node lookup | -| `GET /api/agents/{id}/graphs/{gid}/nodes/{nid}/criteria` | Success criteria | Node criteria + judge verdicts | - -### Logging - -| HTTP Endpoint | Method | Runtime Primitive | -|---|---|---| -| `GET /api/agents/{id}/logs` | Agent logs | `RuntimeLogger` queries | -| `GET /api/agents/{id}/graphs/{gid}/nodes/{nid}/logs` | Node logs | `RuntimeLogger` node-scoped queries | - ---- - -## What Differs Between CLI and HTTP - -The differences are in the **access pattern**, not the runtime behavior. - -| Concern | CLI | HTTP Server | -|---|---|---| -| **Multi-agent** | One runner per process | `AgentManager` holds N slots concurrently | -| **User input** | stdin (shell) / TUI widget | `POST /inject` or `POST /chat` | -| **Event streaming** | `subscribe_to_events()` → TUI update | Same subscription → SSE stream | -| **HITL approval** | `set_approval_callback()` + stdin | `CLIENT_INPUT_REQUESTED` event → `/inject` | -| **Agent lifecycle** | Process start → run → exit | Dynamic load/unload via REST calls | -| **Concurrency** | Sequential (one run at a time) | Async — multiple triggers, multiple agents | -| **Agent discovery** | `hive list` scans dirs | `GET /api/discover` scans dirs (same logic) | -| **Frontend** | Terminal / Textual TUI | React SPA served from `frontend/dist/` | - ---- - -## The AgentManager Bridge - -The only component unique to the HTTP server. It manages the lifecycle of multiple loaded agents within a single process. - -```mermaid -flowchart LR - subgraph AgentManager - S1["Slot: support-agent
runner + runtime + info"] - S2["Slot: research-agent
runner + runtime + info"] - S3["Slot: code-agent
runner + runtime + info"] - end - - Load["POST /api/agents"] -->|"load_agent()"| AgentManager - Unload["DELETE /api/agents/{id}"] -->|"unload_agent()"| AgentManager - List["GET /api/agents"] -->|"list_agents()"| AgentManager - Get["GET /api/agents/{id}"] -->|"get_agent()"| AgentManager - Shutdown["Server shutdown"] -->|"shutdown_all()"| AgentManager -``` - -**Key design choices:** -- **Thread-safe** via `asyncio.Lock` — no race conditions during load/unload -- **Blocking I/O offloaded** — `AgentRunner.load()` runs in `run_in_executor` to avoid blocking the event loop -- **Same pattern as TUI** — the comment in source explicitly notes this: `# Blocking I/O — load in executor (same as tui/app.py:362-368)` - ---- - -## How the `/chat` Endpoint Auto-Routes - -The `/chat` endpoint demonstrates the thin-wrapper pattern. It checks runtime state and delegates: - -``` -POST /api/agents/{id}/chat { "message": "hello" } - │ - ▼ - Is any node waiting for input? - │ │ - YES NO - │ │ - ▼ ▼ - runtime.inject_input() runtime.trigger() - │ │ - ▼ ▼ - { "status": "injected", { "status": "started", - "node_id": "..." } "execution_id": "..." } -``` - -This is the same decision a human makes in the shell — if the agent is waiting for input, provide it; otherwise start a new execution. - ---- - -## Concurrent Judge & Queen: Multi-Graph Monitoring Primitives - -The Worker Health Judge and Queen triage system introduce **secondary graphs** that run alongside a primary worker graph within the same `AgentRuntime`. They share the runtime's `EventBus` but have fully isolated storage. This section documents the new runtime primitives, EventBus events, data models, and storage layout they introduce. - -### Architecture - -``` -One AgentRuntime (shared EventBus) -| -+-- Worker Graph (primary) trigger_type: manual -| Entry point: "start" -> worker node (event_loop, client_facing) -| -+-- Health Judge Graph (secondary) trigger_type: timer (2 min) -| Entry point: "health_check" -> judge node (event_loop, autonomous) -| isolation_level: isolated -| conversation_mode: continuous -| -+-- Queen Graph (secondary) trigger_type: event (worker_escalation_ticket) - Entry point: "ticket_receiver" -> ticket_triage node (event_loop) - isolation_level: isolated -``` - -### GraphScopedEventBus and Event Identity Fields - -Every event carries four identity fields: `(graph_id, stream_id, node_id, execution_id)`. - -- **`graph_id`** — Set automatically by `GraphScopedEventBus`, a public subclass of `EventBus` that stamps `graph_id` on every `publish()` call. All three components (worker, judge, queen) use a scoped bus so their events are distinguishable. -- **`stream_id`** — The entry point pipeline. Flows from `EntryPointSpec.id` through `ExecutionStream` → `GraphExecutor` → `NodeContext` → `EventLoopNode`. -- **`node_id`** — The graph node emitting the event. -- **`execution_id`** — UUID for a specific execution run, set by `ExecutionStream` and wired through `GraphExecutor` → `EventLoopNode` → all `emit_*` calls. - -See [EVENT_TYPES.md](../core/framework/runtime/EVENT_TYPES.md) for the complete event type and schema reference. - -### New EventBus Event Types - -Two new events added to `EventType` enum: - -#### `WORKER_ESCALATION_TICKET` - -Emitted by the health judge's `emit_escalation_ticket` tool when the judge detects a sustained degradation pattern in the worker. - -```python -EventBus.emit_worker_escalation_ticket( - stream_id: str, # Judge's stream ID (e.g. "judge") - node_id: str, # "judge" - ticket: dict, # Full EscalationTicket (see data model below) - execution_id: str | None = None, -) -``` - -**Event data payload:** - -```json -{ - "ticket": { - "ticket_id": "uuid", - "created_at": "ISO8601", - "worker_agent_id": "deep_research_agent", - "worker_session_id": "session_20260223_130234_a30df858", - "worker_node_id": "researcher", - "worker_graph_id": "deep-research-graph", - "severity": "high", - "cause": "Node has produced 18 consecutive RETRY verdicts with identical error...", - "judge_reasoning": "Steps unchanged across last 3 checks. Evidence shows repeated...", - "suggested_action": "Restart node with modified prompt or escalate to human.", - "recent_verdicts": ["RETRY", "RETRY", "RETRY", "CONTINUE", "RETRY"], - "total_steps_checked": 47, - "steps_since_last_accept": 18, - "stall_minutes": null, - "evidence_snippet": "Error: API returned 429 rate limit exceeded..." - } -} -``` - -**Subscribers:** Queen's `ticket_receiver` entry point (event-driven). HTTP server should forward via SSE for dashboard UIs. - -#### `QUEEN_INTERVENTION_REQUESTED` - -Emitted by the queen's `notify_operator` tool after triaging an escalation ticket and deciding the human operator should be involved. - -```python -EventBus.emit_queen_intervention_requested( - stream_id: str, # Queen's stream ID - node_id: str, # "ticket_triage" - ticket_id: str, # References the original EscalationTicket - analysis: str, # Queen's 2-3 sentence analysis - severity: str, # "low" | "medium" | "high" | "critical" - queen_graph_id: str, # "queen" - queen_stream_id: str, # "queen" - execution_id: str | None = None, -) -``` - -**Event data payload:** - -```json -{ - "ticket_id": "uuid", - "analysis": "Worker is stuck in a rate-limit retry loop for 6+ minutes. Suggest pausing and retrying with backoff.", - "severity": "high", - "queen_graph_id": "queen", - "queen_stream_id": "queen" -} -``` - -**Subscribers:** TUI (shows non-disruptive overlay). HTTP server should forward via SSE. - -### New Data Model: EscalationTicket - -```python -# core/framework/runtime/escalation_ticket.py -class EscalationTicket(BaseModel): - ticket_id: str # Auto-generated UUID - created_at: str # Auto-generated ISO8601 - - # Worker identification - worker_agent_id: str # Agent name (e.g. "deep_research_agent") - worker_session_id: str # Session being monitored - worker_node_id: str # Primary graph's entry node - worker_graph_id: str # Primary graph ID - - # Problem characterization (LLM-generated by judge) - severity: Literal["low", "medium", "high", "critical"] - cause: str # What the judge observed - judge_reasoning: str # Why the judge decided to escalate - suggested_action: str # Recommended intervention - - # Evidence - recent_verdicts: list[str] # Last N verdicts (ACCEPT/RETRY/CONTINUE/ESCALATE) - total_steps_checked: int # Total log steps seen - steps_since_last_accept: int - stall_minutes: float | None # Wall-clock since last step (None if active) - evidence_snippet: str # Truncated recent LLM output -``` - -### Modified AgentRuntime APIs - -The following existing methods gained a `graph_id` parameter to support multi-graph routing. When `graph_id=None` (default), the method targets the **active graph** (`active_graph_id`), falling back to the primary graph. Existing callers that pass no `graph_id` are unaffected. - -| Method | New parameter | Notes | -|---|---|---| -| `trigger()` | `graph_id: str \| None = None` | Routes to the named graph's stream | -| `get_entry_points()` | `graph_id: str \| None = None` | Returns entry points for the specified graph | -| `get_stream()` | `graph_id: str \| None = None` | Resolves stream via active graph first | -| `get_execution_result()` | `graph_id: str \| None = None` | Looks up result in the graph's stream | -| `cancel_execution()` | `graph_id: str \| None = None` | Cancels execution in the graph's stream | - -### New AgentRuntime APIs - -| Method | Signature | Description | -|---|---|---| -| `get_active_graph()` | `-> GraphSpec` | Returns the `GraphSpec` for the currently active graph (used by TUI/chat routing) | -| `active_graph_id` (property) | `str` (get/set) | The graph that receives user input. Set by TUI when switching between worker and queen views | -| `get_active_streams()` | `-> list[dict]` | Returns metadata for every stream with active executions across all graphs. Each dict contains `graph_id`, `stream_id`, `entry_point_id`, `active_execution_ids`, `is_awaiting_input`, `waiting_nodes`. | -| `get_waiting_nodes()` | `-> list[dict]` | Flat list of all nodes currently blocked waiting for client input across all graphs/streams. Each dict contains `graph_id`, `stream_id`, `node_id`, `execution_id`. | - -### New ExecutionStream APIs - -| Method | Signature | Description | -|---|---|---| -| `get_waiting_nodes()` | `-> list[dict]` | Returns `[{"node_id": str, "execution_id": str}]` for every `EventLoopNode` with `_awaiting_input == True`. | -| `get_injectable_nodes()` | `-> list[dict]` | Returns `[{"node_id": str, "execution_id": str}]` for every node that supports message injection (has `inject_event` method). | - -### Proposed HTTP Endpoints - -These endpoints are not yet implemented. They expose the new multi-graph and monitoring primitives to the HTTP access layer, following the same thin-delegation pattern as existing endpoints. - -#### Multi-Graph Control - -| HTTP Endpoint | Method | Runtime Primitive | -|---|---|---| -| `POST /api/agents/{id}/graphs` | Load secondary graph | `runtime.add_graph(graph_id, graph, goal, entry_points)` | -| `DELETE /api/agents/{id}/graphs/{gid}` | Unload secondary graph | `runtime.remove_graph(graph_id)` (not yet implemented) | -| `GET /api/agents/{id}/graphs/{gid}/sessions` | List graph sessions | Graph-specific `SessionStore.list_sessions()` | -| `GET /api/agents/{id}/graphs/{gid}/sessions/{sid}` | Graph session details | Graph-specific `SessionStore.read_state()` | -| `PUT /api/agents/{id}/active-graph` | Switch active graph | `runtime.active_graph_id = graph_id` | -| `GET /api/agents/{id}/active-graph` | Get active graph | `runtime.active_graph_id` | - -#### Stream Introspection - -| HTTP Endpoint | Method | Runtime Primitive | -|---|---|---| -| `GET /api/agents/{id}/streams` | Active streams | `runtime.get_active_streams()` — all streams with active executions | -| `GET /api/agents/{id}/waiting-nodes` | Waiting nodes | `runtime.get_waiting_nodes()` — all nodes blocked on client input | - -#### Worker Health Monitoring - -| HTTP Endpoint | Method | Runtime Primitive | -|---|---|---| -| `GET /api/agents/{id}/health` | Health summary | Calls `get_worker_health_summary()` tool (reads worker session logs) | -| `GET /api/agents/{id}/escalations` | List escalation tickets | Query `WORKER_ESCALATION_TICKET` events from EventBus history | -| `GET /api/agents/{id}/escalations/{tid}` | Ticket details | Lookup specific ticket by `ticket_id` | - -#### Event Streaming Additions - -The SSE stream (`GET /api/agents/{id}/events`) should include the two new event types in its default set: - -``` -Default event types: ..., WORKER_ESCALATION_TICKET, QUEEN_INTERVENTION_REQUESTED -``` - -Clients can subscribe selectively: - -``` -GET /api/agents/{id}/events?types=worker_escalation_ticket,queen_intervention_requested -``` - -### Isolated Session Lifecycle for Secondary Graphs - -Isolated entry points (`isolation_level="isolated"`) use **persistent sessions** — a single session is created on first trigger and reused for all subsequent triggers of the same entry point. This is critical for: - -- **Timer-driven** entry points (health judge): one session across all timer ticks, so `conversation_mode="continuous"` works and the judge accumulates observations in its conversation history. -- **Event-driven** entry points (queen ticket receiver): one session across all received events, so the queen can reference prior triage decisions. - -The session reuse is managed by the timer/event handler closures in `AgentRuntime`, which remember the first `execution_id` returned by `stream.execute()` and pass it as `resume_session_id` on all subsequent fires. The `GraphExecutor` detects the existing conversation store, resets the cursor (clearing stale outputs), and appends a transition marker so the LLM knows a new trigger arrived while the conversation thread carries forward. - -### Secondary Graph Storage Layout - -Secondary graphs have fully isolated storage under `graphs/{graph_id}/` to prevent any interference with the primary worker's sessions, logs, and conversations. - -``` -~/.hive/agents/{agent_name}/ -+-- sessions/ # Primary graph only -| +-- session_YYYYMMDD_HHMMSS_{uuid}/ -| +-- state.json -| +-- conversations/ -| +-- logs/ -+-- graphs/ -| +-- judge/ # Health judge (secondary) -| | +-- sessions/ -| | | +-- session_YYYYMMDD_HHMMSS_{uuid}/ # ONE persistent session -| | | +-- state.json -| | | +-- conversations/judge/ # Continuous conversation -| | | +-- logs/ -| | | +-- tool_logs.jsonl -| | | +-- details.jsonl -| | +-- runtime_logs/ -| +-- queen/ # Queen triage (secondary) -| +-- sessions/ -| | +-- session_YYYYMMDD_HHMMSS_{uuid}/ # ONE persistent session -| | +-- state.json -| | +-- conversations/ticket_triage/ -| | +-- logs/ -| +-- runtime_logs/ -+-- runtime_logs/ # Primary graph runtime logs -``` - -Each secondary graph gets its own `SessionStore` and `RuntimeLogStore` scoped to `graphs/{graph_id}/`. This is set up in `AgentRuntime.add_graph()`: - -```python -graph_base = self._session_store.base_path / subpath # e.g. .../graphs/judge -graph_session_store = SessionStore(graph_base) -graph_log_store = RuntimeLogStore(graph_base / "runtime_logs") -``` - -### Worker Monitoring Tools - -Three tools registered via `register_worker_monitoring_tools(registry, event_bus, storage_path)`. These are bound to the worker's EventBus and storage path at registration time. - -| Tool | Used by | Description | -|---|---|---| -| `get_worker_health_summary(session_id?, last_n_steps?)` | Health Judge | Reads worker's `sessions/{id}/logs/tool_logs.jsonl`. Auto-discovers active session if `session_id` omitted. Returns JSON with `worker_agent_id`, `worker_graph_id`, `session_id`, `total_steps`, `recent_verdicts`, `steps_since_last_accept`, `stall_minutes`, `evidence_snippet`. | -| `emit_escalation_ticket(ticket_json)` | Health Judge | Validates JSON against `EscalationTicket` schema (Pydantic rejects partial tickets), then calls `EventBus.emit_worker_escalation_ticket()`. | -| `notify_operator(ticket_id, analysis, urgency)` | Queen | Calls `EventBus.emit_queen_intervention_requested()` so the TUI/frontend surfaces a notification. | - -### Queen Lifecycle Tools - -Four tools registered via `register_queen_lifecycle_tools(registry, worker_runtime, event_bus)`. These close over the worker's `AgentRuntime` to give the Queen control over the worker agent's lifecycle. - -| Tool | Description | -|---|---| -| `start_worker(task)` | Trigger the worker's default entry point with a task description. Returns an `execution_id`. | -| `stop_worker()` | Cancel all active worker executions. Returns IDs of cancelled executions. | -| `get_worker_status()` | Check if the worker is idle, running, or waiting for input. Returns execution details and waiting node ID if applicable. Uses `stream.get_waiting_nodes()` for accurate detection. | -| `inject_worker_message(content)` | Send a message to the running worker agent by finding an injectable node via `stream.get_injectable_nodes()` and calling `stream.inject_input()`. | - -### New File Reference - -| Component | Path | -|---|---| -| EscalationTicket model | `core/framework/runtime/escalation_ticket.py` | -| Worker Health Judge graph | `core/framework/monitoring/judge.py` | -| Worker monitoring tools | `core/framework/tools/worker_monitoring_tools.py` | -| Queen lifecycle tools | `core/framework/tools/queen_lifecycle_tools.py` | -| Monitoring package init | `core/framework/monitoring/__init__.py` | -| Event types reference | `core/framework/runtime/EVENT_TYPES.md` | - ---- - -## File Reference - -| Component | Path | -|---|---| -| CLI entry point | `core/framework/runner/cli.py` | -| HTTP app factory | `core/framework/server/app.py` | -| Agent manager | `core/framework/server/agent_manager.py` | -| Agent routes | `core/framework/server/routes_agents.py` | -| Execution routes | `core/framework/server/routes_execution.py` | -| Event routes | `core/framework/server/routes_events.py` | -| Session routes | `core/framework/server/routes_sessions.py` | -| Graph routes | `core/framework/server/routes_graphs.py` | -| Log routes | `core/framework/server/routes_logs.py` | -| SSE helper | `core/framework/server/sse.py` | -| AgentRunner | `core/framework/runner/runner.py` | -| AgentRuntime | `core/framework/runtime/agent_runtime.py` | -| GraphExecutor | `core/framework/graph/executor.py` | -| SessionStore | `core/framework/storage/session_store.py` | -| CheckpointStore | `core/framework/storage/checkpoint_store.py` | -| Runtime logger | `core/framework/runtime/core.py` | -| EventBus | `core/framework/runtime/event_bus.py` | -| ExecutionStream | `core/framework/runtime/execution_stream.py` | -| GraphScopedEventBus | `core/framework/runtime/execution_stream.py` | -| EscalationTicket | `core/framework/runtime/escalation_ticket.py` | -| Queen lifecycle tools | `core/framework/tools/queen_lifecycle_tools.py` | -| Worker monitoring tools | `core/framework/tools/worker_monitoring_tools.py` | -| Health Judge graph | `core/framework/monitoring/judge.py` | -| Event types reference | `core/framework/runtime/EVENT_TYPES.md` | diff --git a/examples/templates/email_reply_agent/tests/test_structure.py b/examples/templates/email_reply_agent/tests/test_email_reply_agent.py similarity index 100% rename from examples/templates/email_reply_agent/tests/test_structure.py rename to examples/templates/email_reply_agent/tests/test_email_reply_agent.py diff --git a/examples/templates/meeting_scheduler/tests/test_structure.py b/examples/templates/meeting_scheduler/tests/test_meeting_scheduler.py similarity index 100% rename from examples/templates/meeting_scheduler/tests/test_structure.py rename to examples/templates/meeting_scheduler/tests/test_meeting_scheduler.py diff --git a/quickstart.ps1 b/quickstart.ps1 index 211806ef4c..858d2e8994 100644 --- a/quickstart.ps1 +++ b/quickstart.ps1 @@ -66,6 +66,32 @@ function Write-Fail { Write-Color -Text " X $Text" -Color Red } +function Write-CommandFailureDetails { + param( + [object[]]$Output, + [int]$Tail = 40 + ) + + $lines = @($Output | Where-Object { $_ -ne $null } | ForEach-Object { "$_" }) + if ($lines.Count -eq 0) { + return + } + + $start = [Math]::Max(0, $lines.Count - $Tail) + if ($start -gt 0) { + Write-Host " ... showing last $($lines.Count - $start) lines ..." -ForegroundColor DarkGray + } + + for ($i = $start; $i -lt $lines.Count; $i++) { + Write-Host " $($lines[$i])" -ForegroundColor DarkGray + } +} + +function Test-FrontendDistReady { + param([string]$RootDir) + return (Test-Path (Join-Path $RootDir "core\frontend\dist\index.html")) +} + function Prompt-YesNo { param( [string]$Prompt, @@ -540,6 +566,7 @@ Write-Host "" # Build frontend (if Node.js is available) $FrontendBuilt = $false +$FrontendDistReady = Test-FrontendDistReady -RootDir $ScriptDir if ($NodeAvailable) { Write-Step -Number "" -Text "Building frontend dashboard..." Write-Host "" @@ -548,30 +575,33 @@ if ($NodeAvailable) { Write-Host " Installing npm packages... " -NoNewline Push-Location $frontendDir try { - $null = & npm install --no-fund --no-audit 2>&1 + $installOutput = & npm install --no-fund --no-audit 2>&1 if ($LASTEXITCODE -eq 0) { Write-Ok "ok" # Clean stale tsbuildinfo cache — tsc -b incremental builds fail # silently when these are out of sync with source files Get-ChildItem -Path $frontendDir -Filter "tsconfig*.tsbuildinfo" -ErrorAction SilentlyContinue | Remove-Item -Force Write-Host " Building frontend... " -NoNewline - $null = & npm run build 2>&1 + $buildOutput = & npm run build 2>&1 if ($LASTEXITCODE -eq 0) { Write-Ok "ok" Write-Ok "Frontend built -> core/frontend/dist/" $FrontendBuilt = $true + $FrontendDistReady = $true } else { Write-Warn "build failed" - Write-Host " Run 'cd core\frontend && npm run build' manually to debug." -ForegroundColor DarkGray + Write-CommandFailureDetails -Output $buildOutput -Tail 60 + Write-Host " Quickstart will still try '.\hive.ps1 open' if Hive can rebuild the dashboard." -ForegroundColor DarkGray } } else { Write-Warn "npm install failed" - $NodeAvailable = $false + Write-CommandFailureDetails -Output $installOutput -Tail 60 } } finally { Pop-Location } } + $FrontendDistReady = Test-FrontendDistReady -RootDir $ScriptDir Write-Host "" } @@ -2016,13 +2046,21 @@ Write-Color -Text "hive open" -Color Cyan -NoNewline Write-Host ". Run .\quickstart.ps1 again to reconfigure." -ForegroundColor DarkGray Write-Host "" -if ($FrontendBuilt) { - Write-Color -Text "Launching dashboard..." -Color White +if ($FrontendDistReady -or $NodeAvailable) { + if ($FrontendDistReady) { + Write-Color -Text "Launching dashboard..." -Color White + } else { + Write-Color -Text "Launching dashboard (retrying frontend build via hive open)..." -Color White + } Write-Host "" - & hive open + & $hivePs1Path open + if ($LASTEXITCODE -ne 0) { + Write-Warn "Dashboard launch failed" + Write-Host " Run '.\hive.ps1 open' manually to inspect the error." -ForegroundColor DarkGray + } } else { - Write-Color -Text "Frontend build was skipped or failed." -Color Yellow -NoNewline + Write-Color -Text "Frontend build was skipped or failed, and no built dashboard is available." -Color Yellow -NoNewline Write-Host " Launch manually when ready:" - Write-Color -Text " hive open" -Color Cyan + Write-Color -Text " .\hive.ps1 open" -Color Cyan Write-Host "" } diff --git a/quickstart.sh b/quickstart.sh index a5dbd0bb81..e59cf7600f 100755 --- a/quickstart.sh +++ b/quickstart.sh @@ -1792,15 +1792,27 @@ echo "" # Ensure ~/.local/bin exists and is in PATH mkdir -p "$HOME/.local/bin" -# Create/update symlink +# Git Bash on Windows may materialize `ln -s` as a plain file copy. +# Use a launcher shim there, but prefer a real symlink on Linux/macOS. HIVE_SCRIPT="$SCRIPT_DIR/hive" HIVE_LINK="$HOME/.local/bin/hive" +HIVE_SCRIPT_ESCAPED=$(printf '%q' "$HIVE_SCRIPT") if [ -L "$HIVE_LINK" ] || [ -e "$HIVE_LINK" ]; then rm -f "$HIVE_LINK" fi -ln -s "$HIVE_SCRIPT" "$HIVE_LINK" +if [ -n "$MSYSTEM" ] || [ -n "$MINGW_PREFIX" ]; then + cat > "$HIVE_LINK" <&1 + Pop-Location + # Re-detect credentials + $antigravityAccountsPath = Join-Path $env:USERPROFILE ".hive\antigravity-accounts.json" + if (Test-Path $antigravityAccountsPath) { + $AntigravityCredDetected = $true + } + } catch { + Pop-Location + } + + if (-not $AntigravityCredDetected) { + Write-Host "" + Write-Fail "Authentication failed or was cancelled." + Write-Host "" + exit 1 + } + } + + if ($AntigravityCredDetected) { + $SubscriptionMode = "antigravity" + $SelectedProviderId = "openai" + $SelectedModel = "gemini-3-flash" + $SelectedMaxTokens = 32768 + $SelectedMaxContextTokens = 1000000 # Gemini 3 Flash — 1M context window + Write-Host "" + Write-Warn "Using Antigravity can technically cause your account suspension. Please use at your own risk." + Write-Host "" + Write-Ok "Using Antigravity subscription" + Write-Color -Text " Model: gemini-3-flash | Direct OAuth (no proxy required)" -Color DarkGray + } + } + { $_ -ge 8 -and $_ -le 13 } { # API key providers - $provIdx = $num - 6 + $provIdx = $num - 8 $SelectedEnvVar = $ProviderMenuEnvVars[$provIdx] $SelectedProviderId = $ProviderMenuIds[$provIdx] $providerName = $ProviderMenuNames[$provIdx] -replace ' - .*', '' # strip description @@ -732,6 +825,73 @@ if ($SubscriptionMode -eq "zai_code") { } } +# For MiniMax coding key: prompt for API key with verification + retry +if ($SubscriptionMode -eq "minimax_code") { + while ($true) { + $existingMinimax = [System.Environment]::GetEnvironmentVariable("MINIMAX_API_KEY", "User") + if (-not $existingMinimax) { $existingMinimax = $env:MINIMAX_API_KEY } + + if ($existingMinimax) { + $masked = $existingMinimax.Substring(0, [Math]::Min(4, $existingMinimax.Length)) + "..." + $existingMinimax.Substring([Math]::Max(0, $existingMinimax.Length - 4)) + Write-Host "" + Write-Color -Text " $([char]0x2B22) Current MiniMax key: $masked" -Color Green + $apiKey = Read-Host " Press Enter to keep, or paste a new key to replace" + } else { + Write-Host "" + Write-Host "Get your API key from: " -NoNewline + Write-Color -Text "https://platform.minimax.io/user-center/basic-information/interface-key" -Color Cyan + Write-Host "" + $apiKey = Read-Host "Paste your MiniMax API key (or press Enter to skip)" + } + + if ($apiKey) { + [System.Environment]::SetEnvironmentVariable("MINIMAX_API_KEY", $apiKey, "User") + $env:MINIMAX_API_KEY = $apiKey + Write-Host "" + Write-Ok "MiniMax API key saved as User environment variable" + + # Health check the new key + Write-Host " Verifying MiniMax API key... " -NoNewline + try { + Push-Location $ProjectDir + $hcResult = & $UvCmd run python (Join-Path $ProjectDir "scripts/check_llm_key.py") "minimax" $apiKey "https://api.minimax.io/v1" 2>$null + Pop-Location + $hcJson = $hcResult | ConvertFrom-Json + if ($hcJson.valid -eq $true) { + Write-Color -Text "ok" -Color Green + break + } elseif ($hcJson.valid -eq $false) { + Write-Color -Text "failed" -Color Red + Write-Warn $hcJson.message + [System.Environment]::SetEnvironmentVariable("MINIMAX_API_KEY", $null, "User") + Remove-Item -Path "Env:\MINIMAX_API_KEY" -ErrorAction SilentlyContinue + Write-Host "" + Read-Host " Press Enter to try again" + } else { + Write-Color -Text "--" -Color Yellow + Write-Color -Text " Could not verify key (network issue). The key has been saved." -Color DarkGray + break + } + } catch { + Pop-Location + Write-Color -Text "--" -Color Yellow + Write-Color -Text " Could not verify key (network issue). The key has been saved." -Color DarkGray + break + } + } elseif (-not $existingMinimax) { + Write-Host "" + Write-Warn "Skipped. Add your MiniMax API key later:" + Write-Color -Text " [System.Environment]::SetEnvironmentVariable('MINIMAX_API_KEY', 'your-key', 'User')" -Color Cyan + $SelectedEnvVar = "" + $SelectedProviderId = "" + $SubscriptionMode = "" + break + } else { + break + } + } +} + # For Kimi Code subscription: prompt for API key with verification + retry if ($SubscriptionMode -eq "kimi_code") { while ($true) { @@ -910,9 +1070,21 @@ if ($SelectedProviderId) { $workerLlm["use_claude_code_subscription"] = $true } elseif ($SubscriptionMode -eq "codex") { $workerLlm["use_codex_subscription"] = $true + } elseif ($SubscriptionMode -eq "antigravity") { + $workerLlm["use_antigravity_subscription"] = $true + # Pass along any Antigravity OAuth env vars if set + $agSecret = [System.Environment]::GetEnvironmentVariable("ANTIGRAVITY_CLIENT_SECRET", "User") + if (-not $agSecret) { $agSecret = $env:ANTIGRAVITY_CLIENT_SECRET } + if ($agSecret) { $workerLlm["antigravity_client_secret"] = $agSecret } + $agClientId = [System.Environment]::GetEnvironmentVariable("ANTIGRAVITY_CLIENT_ID", "User") + if (-not $agClientId) { $agClientId = $env:ANTIGRAVITY_CLIENT_ID } + if ($agClientId) { $workerLlm["antigravity_client_id"] = $agClientId } } elseif ($SubscriptionMode -eq "zai_code") { $workerLlm["api_base"] = "https://api.z.ai/api/coding/paas/v4" $workerLlm["api_key_env_var"] = $SelectedEnvVar + } elseif ($SubscriptionMode -eq "minimax_code") { + $workerLlm["api_base"] = "https://api.minimax.io/v1" + $workerLlm["api_key_env_var"] = $SelectedEnvVar } elseif ($SubscriptionMode -eq "kimi_code") { $workerLlm["api_base"] = "https://api.kimi.com/coding" $workerLlm["api_key_env_var"] = $SelectedEnvVar diff --git a/tools/pyproject.toml b/tools/pyproject.toml index 643237ba86..91fc848b19 100644 --- a/tools/pyproject.toml +++ b/tools/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "python-dotenv>=1.0.0", "playwright>=1.40.0", "playwright-stealth>=1.0.5", - "litellm>=1.81.0", + "litellm==1.81.7", # pinned: supply chain attack in >=1.82.7 (adenhq/hive#6783) "dnspython>=2.4.0", "resend>=2.0.0", "asana>=3.2.0", diff --git a/tools/test_schema_discovery.py b/tools/schema_discovery.py similarity index 100% rename from tools/test_schema_discovery.py rename to tools/schema_discovery.py diff --git a/tools/src/aden_tools/tools/file_system_toolkits/__init__.py b/tools/src/aden_tools/tools/file_system_toolkits/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/src/aden_tools/tools/openmeteo_tool/README.md b/tools/src/aden_tools/tools/openmeteo_tool/README.md new file mode 100644 index 0000000000..e8105ba451 --- /dev/null +++ b/tools/src/aden_tools/tools/openmeteo_tool/README.md @@ -0,0 +1,19 @@ +# Open-Meteo Weather Tool + +Free weather API for Hive agents. No API key required. + +## Tools + +### weather_get_current +Get current weather for any location. +- latitude (float): e.g. 52.52 for Berlin +- longitude (float): e.g. 13.41 for Berlin + +### weather_get_forecast +Get multi-day forecast for any location. +- latitude (float): Location latitude +- longitude (float): Location longitude +- days (int): 1-16 days (default 7) + +## Authentication +None required. Completely free. diff --git a/tools/src/aden_tools/tools/openmeteo_tool/__init__.py b/tools/src/aden_tools/tools/openmeteo_tool/__init__.py new file mode 100644 index 0000000000..410b21faab --- /dev/null +++ b/tools/src/aden_tools/tools/openmeteo_tool/__init__.py @@ -0,0 +1,3 @@ +from .openmeteo_tool import register_tools + +__all__ = ["register_tools"] diff --git a/tools/src/aden_tools/tools/openmeteo_tool/openmeteo_tool.py b/tools/src/aden_tools/tools/openmeteo_tool/openmeteo_tool.py new file mode 100644 index 0000000000..f04b52b509 --- /dev/null +++ b/tools/src/aden_tools/tools/openmeteo_tool/openmeteo_tool.py @@ -0,0 +1,99 @@ +""" +Open-Meteo Weather Tool - Get real-time weather and forecasts. + +Free, open-source weather API. No API key or authentication required. +Provides current conditions and multi-day forecasts for any location. +""" + +from __future__ import annotations + +import httpx +from fastmcp import FastMCP + + +def register_tools(mcp: FastMCP) -> None: + """Register Open-Meteo weather tools with the MCP server.""" + + @mcp.tool() + def weather_get_current(latitude: float, longitude: float) -> dict: + """ + Get current weather conditions for any location. + + No API key required. Uses the free Open-Meteo API. + + Args: + latitude: Location latitude (e.g. 52.52 for Berlin, 40.71 for New York) + longitude: Location longitude (e.g. 13.41 for Berlin, -74.01 for New York) + + Returns: + Dictionary with current weather: + - temperature: Current temperature in Celsius + - windspeed: Wind speed in km/h + - winddirection: Wind direction in degrees + - weathercode: WMO weather code (0=clear, 1-3=cloudy, 45-48=fog, etc.) + - is_day: 1 if daytime, 0 if nighttime + - time: Observation time (ISO format) + """ + try: + url = "https://api.open-meteo.com/v1/forecast" + params = { + "latitude": latitude, + "longitude": longitude, + "current_weather": True, + } + response = httpx.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + return data.get("current_weather", {"error": "No current weather data returned"}) + except httpx.HTTPStatusError as e: + return {"error": f"API request failed: {e.response.status_code}"} + except Exception as e: + return {"error": str(e)} + + @mcp.tool() + def weather_get_forecast(latitude: float, longitude: float, days: int = 7) -> dict: + """ + Get a multi-day weather forecast for any location. + + No API key required. Uses the free Open-Meteo API. + + Args: + latitude: Location latitude (e.g. 52.52 for Berlin, 40.71 for New York) + longitude: Location longitude (e.g. 13.41 for Berlin, -74.01 for New York) + days: Number of forecast days (1-16, default 7) + + Returns: + Dictionary with daily forecast: + - dates: List of forecast dates + - temperature_max: Daily maximum temperatures (Celsius) + - temperature_min: Daily minimum temperatures (Celsius) + - precipitation: Daily precipitation sum (mm) + - weathercode: Daily WMO weather codes + """ + try: + if not 1 <= days <= 16: + return {"error": "days must be between 1 and 16"} + + url = "https://api.open-meteo.com/v1/forecast" + params = { + "latitude": latitude, + "longitude": longitude, + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode", + "forecast_days": days, + "timezone": "auto", + } + response = httpx.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + daily = data.get("daily", {}) + return { + "dates": daily.get("time", []), + "temperature_max": daily.get("temperature_2m_max", []), + "temperature_min": daily.get("temperature_2m_min", []), + "precipitation": daily.get("precipitation_sum", []), + "weathercode": daily.get("weathercode", []), + } + except httpx.HTTPStatusError as e: + return {"error": f"API request failed: {e.response.status_code}"} + except Exception as e: + return {"error": str(e)} diff --git a/tools/src/aden_tools/tools/pushover_tool/tests/test_pushover_tool.py b/tools/src/aden_tools/tools/pushover_tool/tests/test_pushover_tool.py index 1decc766a7..75cccc40e6 100644 --- a/tools/src/aden_tools/tools/pushover_tool/tests/test_pushover_tool.py +++ b/tools/src/aden_tools/tools/pushover_tool/tests/test_pushover_tool.py @@ -1,99 +1,9 @@ -"""Tests for Pushover tool.""" - from unittest.mock import MagicMock, patch -from aden_tools.tools.pushover_tool.pushover_tool import ( - _PushoverClient, - register_tools, -) - - -class TestPushoverClient: - """Tests for _PushoverClient.""" - - def setup_method(self): - self.client = _PushoverClient( - token="test_token", - user_key="test_user_key", - ) - - def _mock_response(self, status_code=200, json_data=None): - mock = MagicMock() - mock.status_code = status_code - mock.json.return_value = json_data or {"status": 1, "request": "abc123"} - mock.text = "OK" - return mock - - @patch("aden_tools.tools.pushover_tool.pushover_tool.httpx.post") - def test_send_notification_success(self, mock_post): - mock_post.return_value = self._mock_response() - result = self.client.send_notification("Test message", title="Test") - assert result["status"] == 1 - assert result["request"] == "abc123" - - @patch("aden_tools.tools.pushover_tool.pushover_tool.httpx.post") - def test_send_notification_emergency_priority(self, mock_post): - mock_post.return_value = self._mock_response() - _result = self.client.send_notification("Emergency!", priority=2) - call_kwargs = mock_post.call_args[1]["data"] - assert call_kwargs["retry"] == 30 - assert call_kwargs["expire"] == 3600 - - @patch("aden_tools.tools.pushover_tool.pushover_tool.httpx.post") - def test_send_notification_rate_limited(self, mock_post): - mock_post.return_value = self._mock_response(status_code=429) - result = self.client.send_notification("Test") - assert "error" in result - assert "Rate limit" in result["error"] - - @patch("aden_tools.tools.pushover_tool.pushover_tool.httpx.post") - def test_send_notification_api_error(self, mock_post): - mock_post.return_value = self._mock_response( - json_data={"status": 0, "errors": ["invalid token"]} - ) - result = self.client.send_notification("Test") - assert "error" in result - assert "invalid token" in result["error"] - - @patch("aden_tools.tools.pushover_tool.pushover_tool.httpx.post") - def test_send_notification_with_url(self, mock_post): - mock_post.return_value = self._mock_response() - result = self.client.send_notification_with_url( - "Check this out", - url="https://example.com", - url_title="Example", - ) - assert result["status"] == 1 - - @patch("aden_tools.tools.pushover_tool.pushover_tool.httpx.get") - def test_get_sounds(self, mock_get): - mock_get.return_value = self._mock_response( - json_data={"status": 1, "sounds": {"pushover": "Pushover (default)"}} - ) - result = self.client.get_sounds() - assert "sounds" in result - assert "pushover" in result["sounds"] - - @patch("aden_tools.tools.pushover_tool.pushover_tool.httpx.post") - def test_validate_user_success(self, mock_post): - mock_post.return_value = self._mock_response( - json_data={"status": 1, "devices": ["iphone", "android"]} - ) - result = self.client.validate_user() - assert result["status"] == 1 - assert "iphone" in result["devices"] - - @patch("aden_tools.tools.pushover_tool.pushover_tool.httpx.post") - def test_validate_user_with_device(self, mock_post): - mock_post.return_value = self._mock_response(json_data={"status": 1, "devices": ["iphone"]}) - _result = self.client.validate_user(device="iphone") - call_kwargs = mock_post.call_args[1]["data"] - assert call_kwargs["device"] == "iphone" +from aden_tools.tools.pushover_tool.pushover_tool import register_tools class TestRegisterTools: - """Tests for register_tools MCP tool functions.""" - def setup_method(self): self.mcp = MagicMock() self.tools = {} diff --git a/uv.lock b/uv.lock index 1cdb69a3d4..611148526e 100644 --- a/uv.lock +++ b/uv.lock @@ -875,7 +875,7 @@ requires-dist = [ { name = "croniter", specifier = ">=1.4.0" }, { name = "fastmcp", specifier = ">=2.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "litellm", specifier = ">=1.81.0" }, + { name = "litellm", specifier = "==1.81.7" }, { name = "mcp", specifier = ">=1.0.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'testing'", specifier = ">=8.0" }, @@ -3575,7 +3575,7 @@ requires-dist = [ { name = "google-cloud-bigquery", marker = "extra == 'bigquery'", specifier = ">=3.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "jsonpath-ng", specifier = ">=1.6.0" }, - { name = "litellm", specifier = ">=1.81.0" }, + { name = "litellm", specifier = "==1.81.7" }, { name = "openpyxl", marker = "extra == 'all'", specifier = ">=3.1.0" }, { name = "openpyxl", marker = "extra == 'excel'", specifier = ">=3.1.0" }, { name = "pandas", specifier = ">=2.0.0" }, From bf0b9a1edb5e156edea75b56e7ab94e4c48bd289 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Fri, 27 Mar 2026 00:45:42 +0530 Subject: [PATCH 10/10] refactor: cleanup compact llm function --- core/framework/graph/event_loop_node.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/core/framework/graph/event_loop_node.py b/core/framework/graph/event_loop_node.py index 22f8965d2d..9aec856776 100644 --- a/core/framework/graph/event_loop_node.py +++ b/core/framework/graph/event_loop_node.py @@ -25,7 +25,6 @@ from framework.graph.conversation import ConversationStore, NodeConversation from framework.graph.event_loop import types as event_loop_types from framework.graph.event_loop.compaction import ( - _llm_compact_split, build_emergency_summary, build_llm_compaction_prompt, compact, @@ -3085,24 +3084,6 @@ async def _llm_compact( max_context_tokens=self._config.max_context_tokens, ) - async def _llm_compact_split( - self, - ctx: NodeContext, - messages: list, - accumulator: OutputAccumulator | None, - _depth: int, - ) -> str: - """Split messages in half and summarise each half independently.""" - return await _llm_compact_split( - ctx=ctx, - messages=messages, - accumulator=accumulator, - _depth=_depth, - char_limit=self._LLM_COMPACT_CHAR_LIMIT, - max_depth=self._LLM_COMPACT_MAX_DEPTH, - max_context_tokens=self._config.max_context_tokens, - ) - # --- Compaction helpers ------------------------------------------------ @staticmethod