Compare commits

...

37 Commits

Author SHA1 Message Date
Timothy ddefd90e4e feat: agent versioning 2026-02-25 12:00:30 -08:00
Timothy 145860f42e fix: consolidate validation endpoints 2026-02-25 09:54:06 -08:00
bryan d9f84648d0 revoke credential ux 2026-02-25 09:46:46 -08:00
Timothy 9fb7e0bae7 fix: load agent graph consistently 2026-02-25 09:02:37 -08:00
bryan ead85dd41f fix closing tab, remove 0/0 from credential modal 2026-02-25 08:11:24 -08:00
bryan cf5bf6f174 initial prompt from home page 2026-02-25 07:58:49 -08:00
bryan 46237e7309 kill judge and queen 2026-02-24 20:01:43 -08:00
bryan afa686b47b Merge branch 'main' into feat/open-hive 2026-02-24 19:38:46 -08:00
Timothy 21e02c9e50 Merge branch 'fix/credential-popup' into feat/open-hive 2026-02-24 19:29:21 -08:00
Timothy 30a188d7c8 fix: credential popup 2026-02-24 19:28:53 -08:00
bryan 355f51b25e quickstart update 2026-02-24 19:26:54 -08:00
Timothy 8e1cde86e8 Merge branch 'feat/openhive-cred-fixes' into feat/open-hive 2026-02-24 19:10:30 -08:00
Timothy c13b02c7d9 Merge branch 'fix/credential-loading' into feat/open-hive 2026-02-24 19:09:39 -08:00
bryan 9e72801c28 agent loading 2026-02-24 19:05:12 -08:00
RichardTang-Aden 3a3d538b73 Merge pull request #5367 from RichardTang-Aden/feat/codex-subscription-rebased
Feat/codex subscription rebased
2026-02-24 18:53:39 -08:00
Richard Tang b11bca0c67 chore: lint reformat 2026-02-24 18:53:04 -08:00
Richard Tang faf8975b42 chore: improve script code and solved lint errors 2026-02-24 18:51:37 -08:00
Richard Tang 863168880e fix: unused credential detect path removed 2026-02-24 18:41:36 -08:00
Timothy 384a1f0560 fix: credential loading 2026-02-24 18:40:39 -08:00
bryan 4bd1b1b9e6 credential updated 2026-02-24 18:33:09 -08:00
Richard Tang 8c3866a014 feat: optimized for the LLM selection option 2026-02-24 18:27:03 -08:00
Richard Tang 61283d9bd6 feat: Codex subscription OAuth 2026-02-24 18:24:36 -08:00
Richard Tang 585a7186d4 feat: support openai codex subscription as the LLM provider 2026-02-24 18:24:36 -08:00
Timothy 72a31c2a65 fix: credential validity, update api readme 2026-02-24 18:11:10 -08:00
RichardTang-Aden 10d9e54857 Merge pull request #4576 from mubarakar95/perf/reduce-subprocess-spawning-windows
perf: reduce subprocess spawning in quickstart scripts (#4427)
2026-02-24 17:47:22 -08:00
bryan e68695ee92 merge 2026-02-24 17:43:29 -08:00
RichardTang-Aden 11379fc0ef Merge branch 'main' into perf/reduce-subprocess-spawning-windows 2026-02-24 17:43:25 -08:00
Timothy 6d102382bd fix: session id issues 2026-02-24 17:42:09 -08:00
bryan 56335927e7 change from agentid to session id 2026-02-24 15:53:14 -08:00
Timothy a3fe994b22 fix: remove duplicative queen session starter api 2026-02-24 15:14:02 -08:00
Timothy 5754bdcc78 Merge branch 'feature/session-manager' into feat/open-hive 2026-02-24 15:01:01 -08:00
bryan 7286907cd4 multiple agent session running 2026-02-24 14:56:24 -08:00
RichardTang-Aden ebeac68707 Merge pull request #5272 from SANTHAN-KUMAR/main
fix(web_scrape): reorder status checks before wait & replace hardcoded sleep with networkidle
2026-02-24 08:02:41 -08:00
SANTHAN-KUMAR de5fcab933 fix(web_scrape): implement robots.txt support & clean up dead mock
- Add respect_robots_txt parameter (default True) using stdlib
  urllib.robotparser, checked before browser launch to skip
  disallowed URLs early
- Remove dead wait_for_timeout mock from test helper
- Restore respect_robots_txt docs in README (param, error, note)
- Add 2 tests: blocked by robots.txt, disabled robots.txt check
- Fix import ordering (ruff I001)
2026-02-24 15:29:13 +05:30
SANTHAN-KUMAR a7a2100472 Merge branch 'aden-hive:main' into main 2026-02-24 15:24:10 +05:30
SANTHAN-KUMAR 4961d3ba8c fix(web_scrape): reorder status checks & replace hardcoded wait with networkidle
- Move response validation (null, HTTP status, content-type) before
  the rendering wait so errors return immediately without sleeping
- Replace wait_for_timeout(2000) with wait_for_load_state("networkidle")
  to align code with README (timeout=3000, wrapped in try/except)
- Fix README: remove phantom respect_robots_txt param, fix timeout
  30s→60s, remove false robots.txt claim
- Add 3 tests for early-exit error paths
2026-02-23 23:40:10 +05:30
mubarakar95 40e74e408b perf: reduce subprocess spawning in quickstart scripts (#4427)
## Problem
Windows process creation (CreateProcess) is 10-100x slower than Linux fork/exec.
The quickstart scripts were spawning 4+ separate `uv run python -c "import X"`
processes to verify imports, adding ~600ms overhead on Windows.

## Solution
Consolidated all import checks into a single batch script that checks multiple
modules in one subprocess call, reducing spawn overhead by ~75%.

## Changes
- **New**: `scripts/check_requirements.py` - Batched import checker
- **New**: `scripts/test_check_requirements.py` - Test suite
- **New**: `scripts/benchmark_quickstart.ps1` - Performance benchmark tool
- **Modified**: `quickstart.ps1` - Updated import verification (2 sections)
- **Modified**: `quickstart.sh` - Updated import verification

## Performance Impact
**Benchmark results on Windows:**
- Before: ~19.8 seconds for import checks
- After: ~4.9 seconds for import checks
- **Improvement: 14.9 seconds saved (75.2% faster)**

## Testing
-  All functional tests pass (`scripts/test_check_requirements.py`)
-  Quickstart scripts work correctly on Windows
-  Error handling verified (invalid imports reported correctly)
-  Performance benchmark confirms 75%+ improvement

Fixes #4427
2026-02-12 15:38:58 +05:30
63 changed files with 5951 additions and 2208 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: lint format check test install-hooks help frontend-dev frontend-build
.PHONY: lint format check test install-hooks help frontend-install frontend-dev frontend-build
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
@@ -27,6 +27,9 @@ install-hooks: ## Install pre-commit hooks
uv pip install pre-commit
pre-commit install
frontend-install: ## Install frontend npm packages
cd core/frontend && npm install
frontend-dev: ## Start frontend dev server
cd core/frontend && npm run dev
+385
View File
@@ -0,0 +1,385 @@
"""OpenAI Codex OAuth PKCE login flow.
Runs the full browser-based OAuth flow so users can authenticate with their
ChatGPT Plus/Pro subscription without needing the Codex CLI installed.
Usage (from quickstart.sh):
uv run python codex_oauth.py
Exit codes:
0 - success (credentials saved to ~/.codex/auth.json)
1 - failure (user cancelled, timeout, or token exchange error)
"""
import base64
import hashlib
import http.server
import json
import platform
import secrets
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
# OAuth constants (from the Codex CLI binary)
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
TOKEN_URL = "https://auth.openai.com/oauth/token"
REDIRECT_URI = "http://localhost:1455/auth/callback"
SCOPE = "openid profile email offline_access"
CALLBACK_PORT = 1455
# Where to save credentials (same location the Codex CLI uses)
CODEX_AUTH_FILE = Path.home() / ".codex" / "auth.json"
# JWT claim path for account_id
JWT_CLAIM_PATH = "https://api.openai.com/auth"
def _base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def generate_pkce() -> tuple[str, str]:
"""Generate PKCE code_verifier and code_challenge (S256)."""
verifier_bytes = secrets.token_bytes(32)
verifier = _base64url(verifier_bytes)
challenge = _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
def build_authorize_url(state: str, challenge: str) -> str:
"""Build the OpenAI OAuth authorize URL with PKCE."""
params = urllib.parse.urlencode(
{
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
"id_token_add_organizations": "true",
"codex_cli_simplified_flow": "true",
"originator": "hive",
}
)
return f"{AUTHORIZE_URL}?{params}"
def exchange_code_for_tokens(code: str, verifier: str) -> dict | None:
"""Exchange the authorization code for tokens."""
data = urllib.parse.urlencode(
{
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": code,
"code_verifier": verifier,
"redirect_uri": REDIRECT_URI,
}
).encode("utf-8")
req = urllib.request.Request(
TOKEN_URL,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
token_data = json.loads(resp.read())
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError) as exc:
print(f"\033[0;31mToken exchange failed: {exc}\033[0m", file=sys.stderr)
return None
if not token_data.get("access_token") or not token_data.get("refresh_token"):
print("\033[0;31mToken response missing required fields\033[0m", file=sys.stderr)
return None
return token_data
def decode_jwt_payload(token: str) -> dict | None:
"""Decode the payload of a JWT (no signature verification)."""
try:
parts = token.split(".")
if len(parts) != 3:
return None
payload = parts[1]
# Add padding
padding = 4 - len(payload) % 4
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception:
return None
def get_account_id(access_token: str) -> str | None:
"""Extract the ChatGPT account_id from the access token JWT."""
payload = decode_jwt_payload(access_token)
if not payload:
return None
auth = payload.get(JWT_CLAIM_PATH)
if isinstance(auth, dict):
account_id = auth.get("chatgpt_account_id")
if isinstance(account_id, str) and account_id:
return account_id
return None
def save_credentials(token_data: dict, account_id: str) -> None:
"""Save credentials to ~/.codex/auth.json in the same format the Codex CLI uses."""
auth_data = {
"tokens": {
"access_token": token_data["access_token"],
"refresh_token": token_data["refresh_token"],
"account_id": account_id,
},
"auth_mode": "chatgpt",
"last_refresh": datetime.now(UTC).isoformat(),
}
if "id_token" in token_data:
auth_data["tokens"]["id_token"] = token_data["id_token"]
CODEX_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CODEX_AUTH_FILE, "w") as f:
json.dump(auth_data, f, indent=2)
def open_browser(url: str) -> bool:
"""Open the URL in the user's default browser."""
system = platform.system()
try:
devnull = subprocess.DEVNULL
if system == "Darwin":
subprocess.Popen(["open", url], stdout=devnull, stderr=devnull)
elif system == "Windows":
subprocess.Popen(["cmd", "/c", "start", url], stdout=devnull, stderr=devnull)
else:
subprocess.Popen(["xdg-open", url], stdout=devnull, stderr=devnull)
return True
except OSError:
return False
class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
"""HTTP handler that captures the OAuth callback."""
auth_code: str | None = None
received_state: str | None = None
def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path != "/auth/callback":
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not found")
return
params = urllib.parse.parse_qs(parsed.query)
code = params.get("code", [None])[0]
state = params.get("state", [None])[0]
if not code:
self.send_response(400)
self.end_headers()
self.wfile.write(b"Missing authorization code")
return
OAuthCallbackHandler.auth_code = code
OAuthCallbackHandler.received_state = state
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(
b"<!doctype html><html><head><meta charset='utf-8'/></head>"
b"<body><h2>Authentication successful</h2>"
b"<p>Return to your terminal to continue.</p></body></html>"
)
def log_message(self, format: str, *args: object) -> None:
# Suppress request logging
pass
def wait_for_callback(state: str, timeout_secs: int = 120) -> str | None:
"""Start a local HTTP server and wait for the OAuth callback.
Returns the authorization code on success, None on timeout.
"""
OAuthCallbackHandler.auth_code = None
OAuthCallbackHandler.received_state = None
server = http.server.HTTPServer(("127.0.0.1", CALLBACK_PORT), OAuthCallbackHandler)
server.timeout = 1
deadline = time.time() + timeout_secs
server_thread = threading.Thread(target=_serve_until_done, args=(server, deadline, state))
server_thread.daemon = True
server_thread.start()
server_thread.join(timeout=timeout_secs + 2)
server.server_close()
if OAuthCallbackHandler.auth_code and OAuthCallbackHandler.received_state == state:
return OAuthCallbackHandler.auth_code
return None
def _serve_until_done(server: http.server.HTTPServer, deadline: float, state: str) -> None:
while time.time() < deadline:
server.handle_request()
if OAuthCallbackHandler.auth_code and OAuthCallbackHandler.received_state == state:
return
def parse_manual_input(value: str, expected_state: str) -> str | None:
"""Parse user-pasted redirect URL or auth code."""
value = value.strip()
if not value:
return None
try:
parsed = urllib.parse.urlparse(value)
params = urllib.parse.parse_qs(parsed.query)
code = params.get("code", [None])[0]
state = params.get("state", [None])[0]
if state and state != expected_state:
return None
return code
except Exception:
pass
# Maybe it's just the raw code
if len(value) > 10 and " " not in value:
return value
return None
def main() -> int:
# Generate PKCE and state
verifier, challenge = generate_pkce()
state = secrets.token_hex(16)
# Build URL
auth_url = build_authorize_url(state, challenge)
print()
print("\033[1mOpenAI Codex OAuth Login\033[0m")
print()
# Try to start the local callback server first
try:
server_available = True
# Quick test that port is free
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(("127.0.0.1", CALLBACK_PORT))
sock.close()
if result == 0:
print(f"\033[1;33mPort {CALLBACK_PORT} is in use. Using manual paste mode.\033[0m")
server_available = False
except Exception:
server_available = True
# Open browser
browser_opened = open_browser(auth_url)
if browser_opened:
print(" Browser opened for OpenAI sign-in...")
else:
print(" Could not open browser automatically.")
print()
print(" If the browser didn't open, visit this URL:")
print(f" \033[0;36m{auth_url}\033[0m")
print()
code = None
if server_available:
print(" Waiting for authentication (up to 2 minutes)...")
print(" \033[2mOr paste the redirect URL below if the callback didn't work:\033[0m")
print()
# Start callback server in background
callback_result: list[str | None] = [None]
def run_server() -> None:
callback_result[0] = wait_for_callback(state, timeout_secs=120)
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):
print("\n\033[0;31mCancelled.\033[0m")
return 1
if not code:
code = callback_result[0]
else:
# Manual paste mode
try:
manual = input(" Paste the redirect URL: ").strip()
code = parse_manual_input(manual, state)
except (KeyboardInterrupt, EOFError):
print("\n\033[0;31mCancelled.\033[0m")
return 1
if not code:
print("\n\033[0;31mAuthentication timed out or failed.\033[0m")
return 1
# Exchange code for tokens
print()
print(" Exchanging authorization code for tokens...")
token_data = exchange_code_for_tokens(code, verifier)
if not token_data:
return 1
# Extract account_id from JWT
account_id = get_account_id(token_data["access_token"])
if not account_id:
print("\033[0;31mFailed to extract account ID from token.\033[0m", file=sys.stderr)
return 1
# Save credentials
save_credentials(token_data, account_id)
print(" \033[0;32mAuthentication successful!\033[0m")
print(f" Credentials saved to {CODEX_AUTH_FILE}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -36,7 +36,7 @@ Analyze imports, structure, and style in reference agents.
- **Verify assumptions.** Never assume a class, import, or pattern \
exists. Read actual source to confirm. Search if unsure.
- **Discover tools dynamically.** NEVER reference tools from static \
docs. Always run discover_mcp_tools() to see what actually exists.
docs. Always run list_agent_tools() to see what actually exists.
- **Professional objectivity.** If a use case is a poor fit for the \
framework, say so. Technical accuracy over validation.
- **Concise.** No emojis. No preambles. No postambles. Substance only.
@@ -55,8 +55,12 @@ errors yourself. Don't declare success until validation passes.
- undo_changes(path?) restore from git snapshot
## Meta-Agent
- list_agent_tools(server_config_path?) list all tool names available \
for agent building, grouped by category. Call this FIRST before designing.
- discover_mcp_tools(server_config_path?) connect to MCP servers \
and list all available tools with full schemas. Default: hive-tools.
and list all available tools with full schemas. Use for parameter details.
- validate_agent_tools(agent_path) validate that all tools declared \
in an agent's nodes actually exist. Call after building.
- list_agents() list all agent packages in exports/ with session counts
- list_agent_sessions(agent_name, status?, limit?) list sessions
- get_agent_session_state(agent_name, session_id) full session state
@@ -71,14 +75,15 @@ You are not just a file writer. You have deep integration with the \
Hive framework:
## Tool Discovery (MANDATORY before designing)
Before designing any agent, run discover_mcp_tools() to see what \
tools are actually available from the hive-tools MCP server. This \
returns full schemas with parameter names, types, and descriptions. \
NEVER guess tool names or parameters from memory. The tool catalog \
is the ground truth.
Before designing any agent, run list_agent_tools() to get all \
available tool names. ONLY use tools from this list in your node \
definitions. NEVER guess or fabricate tool names from memory.
To check a specific agent's tools:
discover_mcp_tools("exports/{agent_name}/mcp_servers.json")
For full parameter schemas when you need details:
discover_mcp_tools()
To check a specific agent's configured tools:
list_agent_tools("exports/{agent_name}/mcp_servers.json")
## Agent Awareness
Run list_agents() to see what agents already exist. Read their code \
@@ -116,7 +121,7 @@ Ask only what you CANNOT infer. Fill blanks with domain knowledge.
## 2. Qualify
Assess framework fit honestly. Run discover_mcp_tools() to check \
Assess framework fit honestly. Run list_agent_tools() to check \
what tools exist. Read the framework guide:
read_file("core/framework/agents/hive_coder/reference/framework_guide.md")
@@ -277,7 +282,7 @@ STEP 2 — After user responds, call set_output:
**Tools** NEVER fabricate tool names. Common hallucinations: \
csv_read, csv_write, csv_append, file_upload, database_query. \
If discover_mcp_tools() shows these don't exist, use alternatives \
If list_agent_tools() shows these don't exist, use alternatives \
(e.g. save_data/load_data for data persistence).
**Node rules**:
@@ -322,7 +327,7 @@ triggers, use `AsyncEntryPointSpec` (from framework.graph.edge) and \
## 5. Verify
Run THREE validation steps after writing. All must pass:
Run FOUR validation steps after writing. All must pass:
**Step A Class validation** (checks graph structure):
```
@@ -341,7 +346,15 @@ This catches missing __init__.py exports, bad conversation_mode, \
invalid loop_config, and unreachable nodes. If Step A passes but \
Step B fails, the problem is in __init__.py exports.
**Step C Run tests:**
**Step C Tool validation** (checks that declared tools actually exist \
in the agent's MCP servers — catches hallucinated tool names):
```
validate_agent_tools("exports/{name}")
```
If any tools are missing: fix the node definitions to use only tools \
that exist. Run list_agent_tools() to see what's available.
**Step D Run tests:**
```
run_agent_tests("{name}")
```
@@ -374,22 +387,20 @@ set_output until the user is done.
## 7. Live Test (optional)
After the user approves, offer to load and run the agent in-session. \
This runs it alongside you.
After the user approves, offer to load and run the agent in-session.
If running with a queen (server/frontend):
```
load_built_agent("exports/{name}") # loads as the session worker
```
The frontend updates automatically the user sees the agent's graph, \
the tab renames, and you can delegate via start_worker(task).
If running standalone (TUI):
```
load_agent("exports/{name}") # registers as secondary graph
start_agent("{name}") # triggers default entry point
```
You can also:
- `list_agents()` see all loaded graphs and status
- `restart_agent("{name}")` then `load_agent` pick up code changes
- `unload_agent("{name}")` remove it from the session
- `get_user_presence()` check if user is around
The agent runs in a shared session: it can read memory you've set and \
its outputs are visible to you.
""",
tools=[
"read_file",
@@ -400,7 +411,9 @@ its outputs are visible to you.
"run_command",
"undo_changes",
# Meta-agent tools
"list_agent_tools",
"discover_mcp_tools",
"validate_agent_tools",
"list_agents",
"list_agent_sessions",
"get_agent_session_state",
@@ -500,6 +513,7 @@ queen_node = NodeSpec(
"undo_changes",
# Meta-agent (from coder-tools MCP)
"discover_mcp_tools",
"validate_agent_tools",
"list_agents",
"list_agent_sessions",
"get_agent_session_state",
@@ -515,6 +529,8 @@ queen_node = NodeSpec(
# Monitoring
"get_worker_health_summary",
"notify_operator",
# Agent loading
"load_built_agent",
],
system_prompt="""\
You are the Queen the user's primary interface. You are a coding agent \
@@ -530,7 +546,7 @@ Analyze imports, structure, and style in reference agents.
- **Verify assumptions.** Never assume a class, import, or pattern \
exists. Read actual source to confirm. Search if unsure.
- **Discover tools dynamically.** NEVER reference tools from static \
docs. Always run discover_mcp_tools() to see what actually exists.
docs. Always run list_agent_tools() to see what actually exists.
- **Self-verify.** After writing code, run validation and tests. Fix \
errors yourself. Don't declare success until validation passes.
- **Concise.** No emojis. No preambles. No postambles. Substance only.
@@ -547,8 +563,12 @@ errors yourself. Don't declare success until validation passes.
- undo_changes(path?) restore from git snapshot
## Meta-Agent
- list_agent_tools(server_config_path?) list all tool names available \
for agent building, grouped by category. Call this FIRST before designing.
- discover_mcp_tools(server_config_path?) connect to MCP servers \
and list all available tools with full schemas. Default: hive-tools.
and list all available tools with full schemas. Use for parameter details.
- validate_agent_tools(agent_path) validate that all tools declared \
in an agent's nodes actually exist. Call after building.
- list_agents() list all agent packages in exports/ with session counts
- list_agent_sessions(agent_name, status?, limit?) list sessions
- get_agent_session_state(agent_name, session_id) full session state
@@ -571,6 +591,12 @@ Use this to relay user instructions or concerns.
- notify_operator(ticket_id, analysis, urgency) Alert the user about a \
critical issue. Use sparingly.
## Agent Loading
- load_built_agent(agent_path) Load a newly built agent as the worker in \
this session. Call after building and validating an agent to make it \
available immediately. The user sees the graph update and can interact \
with it without leaving the session.
# Behavior
## Direct coding
@@ -611,7 +637,7 @@ You do not need to relay it. The user will come back to you after responding.
When building Hive agent packages, follow this workflow:
## 1. Understand & Qualify
Hear what the user wants. Run discover_mcp_tools() to check tool availability. \
Hear what the user wants. Run list_agent_tools() to check tool availability. \
Read the framework guide:
read_file("core/framework/agents/hive_coder/reference/framework_guide.md")
@@ -630,12 +656,20 @@ Write files: config.py, nodes/__init__.py, agent.py, __init__.py, \
__main__.py, mcp_servers.json, tests/.
## 4. Verify
Run THREE validation steps:
Run FOUR validation steps:
run_command("python -c 'from {name} import default_agent; print(default_agent.validate())'")
run_command("python -c 'from framework.runner.runner import AgentRunner; \\
r = AgentRunner.load(\"exports/{name}\"); print(\"OK\")'")
validate_agent_tools("exports/{name}")
run_agent_tests("{name}")
## 5. Load into Session
After building and verifying, load the agent into the current session:
load_built_agent("exports/{name}")
This makes the agent available immediately the user sees its graph, \
the tab name updates, and you can delegate to it via start_worker(). \
Do NOT tell the user to run `python -m {name} run` load it here.
# Style
- Concise. No fluff. Direct.
@@ -656,7 +690,9 @@ ALL_QUEEN_TOOLS = [
"run_command",
"undo_changes",
# Meta-agent (from coder-tools MCP)
"list_agent_tools",
"discover_mcp_tools",
"validate_agent_tools",
"list_agents",
"list_agent_sessions",
"get_agent_session_state",
@@ -672,6 +708,8 @@ ALL_QUEEN_TOOLS = [
# Monitoring
"get_worker_health_summary",
"notify_operator",
# Agent loading
"load_built_agent",
]
__all__ = [
@@ -24,7 +24,7 @@
9. **Invalid `loop_config` keys** — Only three valid keys: `max_iterations` (int), `max_tool_calls_per_turn` (int), `max_history_tokens` (int). Keys like `"strategy"`, `"mode"`, `"timeout"` are NOT valid and are silently ignored or cause errors.
10. **Fabricating tools that don't exist** — Never guess tool names. Always verify via `discover_mcp_tools()`. Common hallucinations: `csv_read`, `csv_write`, `csv_append`, `file_upload`, `database_query`. If a required tool doesn't exist, redesign the agent to use tools that DO exist (e.g., `save_data`/`load_data` for data persistence).
10. **Fabricating tools that don't exist** — Never guess tool names. Always verify via `list_agent_tools()` before designing and `validate_agent_tools()` after building. Common hallucinations: `csv_read`, `csv_write`, `csv_append`, `file_upload`, `database_query`, `bulk_fetch_emails`. If a required tool doesn't exist, redesign the agent to use tools that DO exist (e.g., `save_data`/`load_data` for data persistence).
## Design Errors
@@ -413,16 +413,18 @@ See `exports/gmail_inbox_guardian/agent.py` for a complete example with:
## Tool Discovery
Do NOT rely on a static tool list — it will be outdated. Always use
`discover_mcp_tools()` to get the current tool catalog from the
hive-tools MCP server. This returns full schemas including parameter
names, types, and descriptions.
`list_agent_tools()` to get available tool names grouped by category.
For full schemas with parameter details, use `discover_mcp_tools()`.
```
discover_mcp_tools() # default: hive-tools
discover_mcp_tools("exports/my_agent/mcp_servers.json") # specific agent
list_agent_tools() # all available tools
list_agent_tools("exports/my_agent/mcp_servers.json") # specific agent
discover_mcp_tools() # full schemas with params
```
Common tool categories (verify via discover_mcp_tools):
After building, validate tools exist: `validate_agent_tools("exports/{name}")`
Common tool categories (verify via list_agent_tools):
- **Web**: search, scrape, PDF
- **Data**: save/load/append/list data files, serve to user
- **File**: view, write, replace, diff, list, grep
+44 -3
View File
@@ -50,12 +50,14 @@ def get_max_tokens() -> int:
def get_api_key() -> str | None:
"""Return the API key, supporting env var, Claude Code subscription, and ZAI Code.
"""Return the API key, supporting env var, Claude Code subscription, Codex, and ZAI Code.
Priority:
1. Claude Code subscription (``use_claude_code_subscription: true``)
reads the OAuth token from ``~/.claude/.credentials.json``.
2. Environment variable named in ``api_key_env_var``.
2. Codex subscription (``use_codex_subscription: true``)
reads the OAuth token from macOS Keychain or ``~/.codex/auth.json``.
3. Environment variable named in ``api_key_env_var``.
"""
llm = get_hive_config().get("llm", {})
@@ -70,6 +72,17 @@ def get_api_key() -> str | None:
except ImportError:
pass
# Codex subscription: read OAuth token from Keychain / auth.json
if llm.get("use_codex_subscription"):
try:
from framework.runner.runner import get_codex_token
token = get_codex_token()
if token:
return token
except ImportError:
pass
# Standard env-var path (covers ZAI Code and all API-key providers)
api_key_env_var = llm.get("api_key_env_var")
if api_key_env_var:
@@ -79,7 +92,11 @@ def get_api_key() -> str | None:
def get_api_base() -> str | None:
"""Return the api_base URL for OpenAI-compatible endpoints, if configured."""
return get_hive_config().get("llm", {}).get("api_base")
llm = get_hive_config().get("llm", {})
if llm.get("use_codex_subscription"):
# Codex subscription routes through the ChatGPT backend, not api.openai.com.
return "https://chatgpt.com/backend-api/codex"
return llm.get("api_base")
def get_llm_extra_kwargs() -> dict[str, Any]:
@@ -88,6 +105,10 @@ def get_llm_extra_kwargs() -> dict[str, Any]:
When ``use_claude_code_subscription`` is enabled, returns
``extra_headers`` with the OAuth Bearer token so that litellm's
built-in Anthropic OAuth handler adds the required beta headers.
When ``use_codex_subscription`` is enabled, returns
``extra_headers`` with the Bearer token, ``ChatGPT-Account-Id``,
and ``store=False`` (required by the ChatGPT backend).
"""
llm = get_hive_config().get("llm", {})
if llm.get("use_claude_code_subscription"):
@@ -96,6 +117,26 @@ def get_llm_extra_kwargs() -> dict[str, Any]:
return {
"extra_headers": {"authorization": f"Bearer {api_key}"},
}
if llm.get("use_codex_subscription"):
api_key = get_api_key()
if api_key:
headers: dict[str, str] = {
"Authorization": f"Bearer {api_key}",
"User-Agent": "CodexBar",
}
try:
from framework.runner.runner import get_codex_account_id
account_id = get_codex_account_id()
if account_id:
headers["ChatGPT-Account-Id"] = account_id
except ImportError:
pass
return {
"extra_headers": headers,
"store": False,
"allowed_openai_params": ["store"],
}
return {}
+10 -3
View File
@@ -63,7 +63,7 @@ from .setup import (
CredentialSetupSession,
MissingCredential,
SetupResult,
detect_missing_credentials_from_nodes,
load_agent_nodes,
run_credential_setup_cli,
)
from .storage import (
@@ -75,7 +75,12 @@ from .storage import (
)
from .store import CredentialStore
from .template import TemplateResolver
from .validation import ensure_credential_key_env, validate_agent_credentials
from .validation import (
CredentialStatus,
CredentialValidationResult,
ensure_credential_key_env,
validate_agent_credentials,
)
# Aden sync components (lazy import to avoid httpx dependency when not needed)
# Usage: from core.framework.credentials.aden import AdenSyncProvider
@@ -130,11 +135,13 @@ __all__ = [
# Validation
"ensure_credential_key_env",
"validate_agent_credentials",
"CredentialStatus",
"CredentialValidationResult",
# Interactive setup
"CredentialSetupSession",
"MissingCredential",
"SetupResult",
"detect_missing_credentials_from_nodes",
"load_agent_nodes",
"run_credential_setup_cli",
# Aden sync (optional - requires httpx)
"AdenSyncProvider",
+24 -127
View File
@@ -160,7 +160,10 @@ class CredentialSetupSession:
@classmethod
def from_nodes(cls, nodes: list[NodeSpec]) -> CredentialSetupSession:
"""Create a setup session by detecting missing credentials from nodes."""
missing = detect_missing_credentials_from_nodes(nodes)
from framework.credentials.validation import _status_to_missing, validate_agent_credentials
result = validate_agent_credentials(nodes, verify=False, raise_on_error=False)
missing = [_status_to_missing(c) for c in result.credentials if not c.available]
return cls(missing)
@classmethod
@@ -178,22 +181,15 @@ class CredentialSetupSession:
are NOT yet available. If False, include all required
credentials regardless of availability.
"""
agent_path = Path(agent_path)
from framework.credentials.validation import _status_to_missing, validate_agent_credentials
# Load agent to get nodes
agent_json = agent_path / "agent.json"
agent_py = agent_path / "agent.py"
nodes = []
if agent_py.exists():
# Python-based agent
nodes = _load_nodes_from_python_agent(agent_path)
elif agent_json.exists():
# JSON-based agent
nodes = _load_nodes_from_json_agent(agent_json)
creds = detect_missing_credentials_from_nodes(nodes, missing_only=missing_only)
return cls(creds)
nodes = load_agent_nodes(agent_path)
result = validate_agent_credentials(nodes, verify=False, raise_on_error=False)
if missing_only:
missing = [_status_to_missing(c) for c in result.credentials if not c.available]
else:
missing = [_status_to_missing(c) for c in result.credentials]
return cls(missing)
def run_interactive(self) -> SetupResult:
"""Run the interactive setup flow."""
@@ -564,123 +560,24 @@ class CredentialSetupSession:
self._print("")
def detect_missing_credentials_from_nodes(
nodes: list,
*,
missing_only: bool = True,
) -> list[MissingCredential]:
"""
Detect credentials required by a list of nodes.
def load_agent_nodes(agent_path: str | Path) -> list:
"""Load NodeSpec list from an agent's agent.py or agent.json.
Args:
nodes: List of NodeSpec objects
missing_only: If True (default), only return credentials that are
NOT yet available. If False, return ALL required credentials
regardless of availability.
agent_path: Path to agent directory.
Returns:
List of MissingCredential objects for credentials that need setup
(or all required credentials when missing_only=False).
List of NodeSpec objects (empty list if agent can't be loaded).
"""
try:
from aden_tools.credentials import CREDENTIAL_SPECS
agent_path = Path(agent_path)
agent_py = agent_path / "agent.py"
agent_json = agent_path / "agent.json"
from framework.credentials import CredentialStore
from framework.credentials.storage import (
CompositeStorage,
EncryptedFileStorage,
EnvVarStorage,
)
except ImportError:
return []
# Collect required tools and node types
required_tools: set[str] = set()
node_types: set[str] = set()
for node in nodes:
if hasattr(node, "tools") and node.tools:
required_tools.update(node.tools)
if hasattr(node, "node_type"):
node_types.add(node.node_type)
# Build credential store to check availability.
# Env vars take priority over encrypted store (fresh key wins over stale).
env_mapping = {
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
}
env_storage = EnvVarStorage(env_mapping=env_mapping)
if os.environ.get("HIVE_CREDENTIAL_KEY"):
storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()])
else:
storage = env_storage
store = CredentialStore(storage=storage)
# Build reverse mappings
tool_to_cred: dict[str, str] = {}
node_type_to_cred: dict[str, str] = {}
for cred_name, spec in CREDENTIAL_SPECS.items():
for tool_name in spec.tools:
tool_to_cred[tool_name] = cred_name
for nt in spec.node_types:
node_type_to_cred[nt] = cred_name
missing: list[MissingCredential] = []
checked: set[str] = set()
# Check tool credentials
for tool_name in sorted(required_tools):
cred_name = tool_to_cred.get(tool_name)
if cred_name is None or cred_name in checked:
continue
checked.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
if spec.required and (not missing_only or not store.is_available(cred_id)):
affected_tools = sorted(t for t in required_tools if t in spec.tools)
missing.append(
MissingCredential(
credential_name=cred_name,
env_var=spec.env_var,
description=spec.description,
help_url=spec.help_url,
api_key_instructions=spec.api_key_instructions,
tools=affected_tools,
aden_supported=spec.aden_supported,
direct_api_key_supported=spec.direct_api_key_supported,
credential_id=spec.credential_id,
credential_key=spec.credential_key,
)
)
# Check node type credentials
for nt in sorted(node_types):
cred_name = node_type_to_cred.get(nt)
if cred_name is None or cred_name in checked:
continue
checked.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
if spec.required and (not missing_only or not store.is_available(cred_id)):
affected_types = sorted(t for t in node_types if t in spec.node_types)
missing.append(
MissingCredential(
credential_name=cred_name,
env_var=spec.env_var,
description=spec.description,
help_url=spec.help_url,
api_key_instructions=spec.api_key_instructions,
node_types=affected_types,
aden_supported=spec.aden_supported,
direct_api_key_supported=spec.direct_api_key_supported,
credential_id=spec.credential_id,
credential_key=spec.credential_key,
)
)
return missing
if agent_py.exists():
return _load_nodes_from_python_agent(agent_path)
elif agent_json.exists():
return _load_nodes_from_json_agent(agent_json)
return []
def _load_nodes_from_python_agent(agent_path: Path) -> list:
+1 -1
View File
@@ -399,7 +399,7 @@ class CredentialStore:
# LLMs sometimes pass "provider/alias" as the alias (e.g. "google/wrok"
# instead of just "wrok"). Strip the provider prefix when present.
if alias.startswith(f"{provider_name}/"):
alias = alias[len(provider_name) + 1:]
alias = alias[len(provider_name) + 1 :]
if hasattr(self._storage, "load_by_alias"):
return self._storage.load_by_alias(provider_name, alias)
+198 -117
View File
@@ -53,14 +53,97 @@ def ensure_credential_key_env() -> None:
@dataclass
class _CredentialCheck:
"""Result of checking a single credential."""
class CredentialStatus:
"""Status of a single required credential after validation."""
credential_name: str
credential_id: str
env_var: str
source: str
used_by: str
description: str
help_url: str
api_key_instructions: str
tools: list[str]
node_types: list[str]
available: bool
help_url: str = ""
valid: bool | None # None = not checked
validation_message: str | None
aden_supported: bool
direct_api_key_supported: bool
credential_key: str
aden_not_connected: bool # Aden-only cred, ADEN_API_KEY set, but integration missing
@dataclass
class CredentialValidationResult:
"""Result of validating all credentials required by an agent."""
credentials: list[CredentialStatus]
has_aden_key: bool
@property
def failed(self) -> list[CredentialStatus]:
"""Credentials that are missing, invalid, or Aden-not-connected."""
return [c for c in self.credentials if not c.available or c.valid is False]
@property
def has_errors(self) -> bool:
return bool(self.failed)
@property
def failed_cred_names(self) -> list[str]:
"""Credential names that need (re-)collection, excluding Aden-not-connected."""
return [c.credential_name for c in self.failed if not c.aden_not_connected]
def format_error_message(self) -> str:
"""Format a human-readable error message for CLI/runner output."""
missing = [c for c in self.credentials if not c.available and not c.aden_not_connected]
invalid = [c for c in self.credentials if c.available and c.valid is False]
aden_nc = [c for c in self.credentials if c.aden_not_connected]
lines: list[str] = []
if missing:
lines.append("Missing credentials:\n")
for c in missing:
entry = f" {c.env_var} for {_label(c)}"
if c.help_url:
entry += f"\n Get it at: {c.help_url}"
lines.append(entry)
if invalid:
if missing:
lines.append("")
lines.append("Invalid or expired credentials:\n")
for c in invalid:
entry = f" {c.env_var} for {_label(c)}{c.validation_message}"
if c.help_url:
entry += f"\n Get a new key at: {c.help_url}"
lines.append(entry)
if aden_nc:
if missing or invalid:
lines.append("")
lines.append(
"Aden integrations not connected "
"(ADEN_API_KEY is set but OAuth tokens unavailable):\n"
)
for c in aden_nc:
lines.append(
f" {c.env_var} for {_label(c)}"
f"\n Connect this integration at hive.adenhq.com first."
)
lines.append(
"\nTo fix: run /hive-credentials in Claude Code."
"\nIf you've already set up credentials, "
"restart your terminal to load them."
)
return "\n".join(lines)
def _label(c: CredentialStatus) -> str:
"""Build a human-readable label from tools/node_types."""
if c.tools:
return ", ".join(c.tools)
if c.node_types:
return ", ".join(c.node_types) + " nodes"
return c.credential_name
def _presync_aden_tokens(credential_specs: dict) -> None:
@@ -112,7 +195,12 @@ def _presync_aden_tokens(credential_specs: dict) -> None:
)
def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool = True) -> None:
def validate_agent_credentials(
nodes: list,
quiet: bool = False,
verify: bool = True,
raise_on_error: bool = True,
) -> CredentialValidationResult:
"""Check that required credentials are available and valid before running an agent.
Two-phase validation:
@@ -124,15 +212,27 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
nodes: List of NodeSpec objects from the agent graph.
quiet: If True, suppress the credential summary output.
verify: If True (default), run health checks on present credentials.
raise_on_error: If True (default), raise CredentialError when validation
fails. Set to False to get the result without raising.
Returns:
CredentialValidationResult with status of ALL required credentials.
"""
empty_result = CredentialValidationResult(credentials=[], has_aden_key=False)
# Collect required tools and node types
required_tools = {tool for node in nodes if node.tools for tool in node.tools}
node_types = {node.node_type for node in nodes}
required_tools: set[str] = set()
node_types: set[str] = set()
for node in nodes:
if hasattr(node, "tools") and node.tools:
required_tools.update(node.tools)
if hasattr(node, "node_type"):
node_types.add(node.node_type)
try:
from aden_tools.credentials import CREDENTIAL_SPECS
except ImportError:
return # aden_tools not installed, skip check
return empty_result # aden_tools not installed, skip check
from framework.credentials.storage import CompositeStorage, EncryptedFileStorage, EnvVarStorage
from framework.credentials.store import CredentialStore
@@ -166,35 +266,45 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
for nt in spec.node_types:
node_type_to_cred[nt] = cred_name
missing: list[str] = []
invalid: list[str] = []
# Aden-backed creds where ADEN_API_KEY is set but integration not connected
aden_not_connected: list[str] = []
failed_cred_names: list[str] = [] # all cred names that need (re-)collection
has_aden_key = bool(os.environ.get("ADEN_API_KEY"))
checked: set[str] = set()
all_credentials: list[CredentialStatus] = []
# Credentials that are present and should be health-checked
to_verify: list[tuple[str, str]] = [] # (cred_name, used_by_label)
to_verify: list[int] = [] # indices into all_credentials
def _check_credential(spec, cred_name: str, label: str) -> None:
def _check_credential(spec, cred_name: str, affected_tools: list[str], affected_node_types: list[str]) -> None:
cred_id = spec.credential_id or cred_name
if not store.is_available(cred_id):
# If ADEN_API_KEY is set and this is an Aden-only credential,
# the issue is that the integration isn't connected on hive.adenhq.com,
# NOT that the user needs to re-enter ADEN_API_KEY.
if has_aden_key and spec.aden_supported and not spec.direct_api_key_supported:
aden_not_connected.append(
f" {spec.env_var} for {label}"
f"\n Connect this integration at hive.adenhq.com first."
)
else:
entry = f" {spec.env_var} for {label}"
if spec.help_url:
entry += f"\n Get it at: {spec.help_url}"
missing.append(entry)
failed_cred_names.append(cred_name)
elif verify and spec.health_check_endpoint:
to_verify.append((cred_name, label))
available = store.is_available(cred_id)
# Aden-not-connected: ADEN_API_KEY set, Aden-only cred, but integration missing
is_aden_nc = (
not available
and has_aden_key
and spec.aden_supported
and not spec.direct_api_key_supported
)
status = CredentialStatus(
credential_name=cred_name,
credential_id=cred_id,
env_var=spec.env_var,
description=spec.description,
help_url=spec.help_url,
api_key_instructions=getattr(spec, "api_key_instructions", ""),
tools=affected_tools,
node_types=affected_node_types,
available=available,
valid=None,
validation_message=None,
aden_supported=spec.aden_supported,
direct_api_key_supported=spec.direct_api_key_supported,
credential_key=spec.credential_key,
aden_not_connected=is_aden_nc,
)
all_credentials.append(status)
if available and verify and spec.health_check_endpoint:
to_verify.append(len(all_credentials) - 1)
# Check tool credentials
for tool_name in sorted(required_tools):
@@ -206,8 +316,7 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
if not spec.required:
continue
affected = sorted(t for t in required_tools if t in spec.tools)
label = ", ".join(affected)
_check_credential(spec, cred_name, label)
_check_credential(spec, cred_name, affected_tools=affected, affected_node_types=[])
# Check node type credentials (e.g., ANTHROPIC_API_KEY for LLM nodes)
for nt in sorted(node_types):
@@ -219,8 +328,7 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
if not spec.required:
continue
affected_types = sorted(t for t in node_types if t in spec.node_types)
label = ", ".join(affected_types) + " nodes"
_check_credential(spec, cred_name, label)
_check_credential(spec, cred_name, affected_tools=[], affected_node_types=affected_types)
# Phase 2: health-check present credentials
if to_verify:
@@ -230,69 +338,52 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
check_credential_health = None # type: ignore[assignment]
if check_credential_health is not None:
for cred_name, label in to_verify:
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
value = store.get(cred_id)
for idx in to_verify:
status = all_credentials[idx]
spec = CREDENTIAL_SPECS[status.credential_name]
value = store.get(status.credential_id)
if not value:
continue
try:
result = check_credential_health(
cred_name,
status.credential_name,
value,
health_check_endpoint=spec.health_check_endpoint,
health_check_method=spec.health_check_method,
)
if not result.valid:
entry = f" {spec.env_var} for {label}{result.message}"
if spec.help_url:
entry += f"\n Get a new key at: {spec.help_url}"
invalid.append(entry)
failed_cred_names.append(cred_name)
elif result.valid:
status.valid = result.valid
status.validation_message = result.message
if result.valid:
# Persist identity from health check (best-effort)
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)
cred_obj = store.get_credential(
status.credential_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
except Exception as exc:
logger.debug("Health check for %s failed: %s", cred_name, exc)
logger.debug("Health check for %s failed: %s", status.credential_name, exc)
errors = missing + invalid + aden_not_connected
if errors:
validation_result = CredentialValidationResult(
credentials=all_credentials,
has_aden_key=has_aden_key,
)
if raise_on_error and validation_result.has_errors:
from framework.credentials.models import CredentialError
lines: list[str] = []
if missing:
lines.append("Missing credentials:\n")
lines.extend(missing)
if invalid:
if missing:
lines.append("")
lines.append("Invalid or expired credentials:\n")
lines.extend(invalid)
if aden_not_connected:
if missing or invalid:
lines.append("")
lines.append(
"Aden integrations not connected "
"(ADEN_API_KEY is set but OAuth tokens unavailable):\n"
)
lines.extend(aden_not_connected)
lines.append(
"\nTo fix: run /hive-credentials in Claude Code."
"\nIf you've already set up credentials, "
"restart your terminal to load them."
)
exc = CredentialError("\n".join(lines))
exc.failed_cred_names = failed_cred_names # type: ignore[attr-defined]
exc = CredentialError(validation_result.format_error_message())
exc.validation_result = validation_result # type: ignore[attr-defined]
exc.failed_cred_names = validation_result.failed_cred_names # type: ignore[attr-defined]
raise exc
return validation_result
def build_setup_session_from_error(
credential_error: Exception,
@@ -301,12 +392,8 @@ def build_setup_session_from_error(
):
"""Build a ``CredentialSetupSession`` that covers all failed credentials.
``validate_agent_credentials`` attaches ``failed_cred_names`` (both missing
and invalid) to the ``CredentialError``. This helper converts those names
into ``MissingCredential`` entries so the setup screen can re-collect them.
Falls back to the normal ``from_nodes`` / ``from_agent_path`` detection
when the attribute is absent.
Uses the ``CredentialValidationResult`` attached to the ``CredentialError``
when available. Falls back to re-detecting from nodes / agent_path.
Args:
credential_error: The ``CredentialError`` raised by validation.
@@ -315,42 +402,36 @@ def build_setup_session_from_error(
"""
from framework.credentials.setup import CredentialSetupSession, MissingCredential
# Start with normal detection (picks up truly missing creds)
# Prefer the validation result attached to the exception
result: CredentialValidationResult | None = getattr(
credential_error, "validation_result", None
)
if result is not None:
missing = [_status_to_missing(c) for c in result.failed]
return CredentialSetupSession(missing)
# Fallback: re-detect from nodes or agent_path
if nodes is not None:
session = CredentialSetupSession.from_nodes(nodes)
return CredentialSetupSession.from_nodes(nodes)
elif agent_path is not None:
session = CredentialSetupSession.from_agent_path(agent_path)
else:
session = CredentialSetupSession(missing=[])
return CredentialSetupSession.from_agent_path(agent_path)
return CredentialSetupSession(missing=[])
# Add credentials that are present but failed health checks
already = {m.credential_name for m in session.missing}
failed_names: list[str] = getattr(credential_error, "failed_cred_names", [])
if failed_names:
try:
from aden_tools.credentials import CREDENTIAL_SPECS
for name in failed_names:
if name in already:
continue
spec = CREDENTIAL_SPECS.get(name)
if spec is None:
continue
session.missing.append(
MissingCredential(
credential_name=name,
env_var=spec.env_var,
description=spec.description,
help_url=spec.help_url,
api_key_instructions=spec.api_key_instructions,
tools=list(spec.tools),
aden_supported=spec.aden_supported,
direct_api_key_supported=spec.direct_api_key_supported,
credential_id=spec.credential_id,
credential_key=spec.credential_key,
)
)
except ImportError:
pass
def _status_to_missing(c: CredentialStatus):
"""Convert a CredentialStatus to a MissingCredential for the setup flow."""
from framework.credentials.setup import MissingCredential
return session
return MissingCredential(
credential_name=c.credential_name,
env_var=c.env_var,
description=c.description,
help_url=c.help_url,
api_key_instructions=c.api_key_instructions,
tools=c.tools,
node_types=c.node_types,
aden_supported=c.aden_supported,
direct_api_key_supported=c.direct_api_key_supported,
credential_id=c.credential_id,
credential_key=c.credential_key,
)
+86
View File
@@ -275,6 +275,9 @@ class LiteLLMProvider(LLMProvider):
self.api_key = api_key
self.api_base = api_base
self.extra_kwargs = kwargs
# The Codex ChatGPT backend (chatgpt.com/backend-api/codex) rejects
# several standard OpenAI params: max_output_tokens, stream_options.
self._codex_backend = bool(api_base and "chatgpt.com/backend-api/codex" in api_base)
if litellm is None:
raise ImportError(
@@ -393,6 +396,43 @@ class LiteLLMProvider(LLMProvider):
# unreachable, but satisfies type checker
raise RuntimeError("Exhausted rate limit retries")
def _codex_sync_complete(self, kwargs: dict[str, Any]) -> "LLMResponse":
"""Collect a streaming Codex response into a single LLMResponse.
The ChatGPT Codex backend only supports ``stream=True``, so non-streaming
callers go through this helper which forces streaming, accumulates the
chunks, and returns the same LLMResponse that ``complete()`` would.
"""
kwargs["stream"] = True
response = litellm.completion(**kwargs) # type: ignore[union-attr]
content = ""
model_name = self.model
input_tokens = 0
output_tokens = 0
finish_reason = ""
for chunk in response:
choice = chunk.choices[0] if chunk.choices else None
if not choice:
continue
delta = choice.delta
if delta and delta.content:
content += delta.content
if choice.finish_reason:
finish_reason = choice.finish_reason
if hasattr(chunk, "usage") and chunk.usage:
input_tokens = getattr(chunk.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(chunk.usage, "completion_tokens", 0) or 0
if chunk.model:
model_name = chunk.model
return LLMResponse(
content=content,
model=model_name,
input_tokens=input_tokens,
output_tokens=output_tokens,
stop_reason=finish_reason,
raw_response=None,
)
def complete(
self,
messages: list[dict[str, Any]],
@@ -441,6 +481,11 @@ class LiteLLMProvider(LLMProvider):
if response_format:
kwargs["response_format"] = response_format
# Codex ChatGPT backend requires streaming and rejects max_output_tokens.
if self._codex_backend:
kwargs.pop("max_tokens", None)
return self._codex_sync_complete(kwargs)
# Make the call
response = self._completion_with_rate_limit_retry(max_retries=max_retries, **kwargs)
@@ -737,6 +782,11 @@ class LiteLLMProvider(LLMProvider):
if response_format:
kwargs["response_format"] = response_format
# Codex ChatGPT backend requires streaming and rejects max_output_tokens.
if self._codex_backend:
kwargs.pop("max_tokens", None)
return await self._codex_async_complete(kwargs)
response = await self._acompletion_with_rate_limit_retry(max_retries=max_retries, **kwargs)
content = response.choices[0].message.content or ""
@@ -753,6 +803,38 @@ class LiteLLMProvider(LLMProvider):
raw_response=response,
)
async def _codex_async_complete(self, kwargs: dict[str, Any]) -> "LLMResponse":
"""Async version of _codex_sync_complete."""
kwargs["stream"] = True
response = await litellm.acompletion(**kwargs) # type: ignore[union-attr]
content = ""
model_name = self.model
input_tokens = 0
output_tokens = 0
finish_reason = ""
async for chunk in response:
choice = chunk.choices[0] if chunk.choices else None
if not choice:
continue
delta = choice.delta
if delta and delta.content:
content += delta.content
if choice.finish_reason:
finish_reason = choice.finish_reason
if hasattr(chunk, "usage") and chunk.usage:
input_tokens = getattr(chunk.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(chunk.usage, "completion_tokens", 0) or 0
if chunk.model:
model_name = chunk.model
return LLMResponse(
content=content,
model=model_name,
input_tokens=input_tokens,
output_tokens=output_tokens,
stop_reason=finish_reason,
raw_response=None,
)
async def acomplete_with_tools(
self,
messages: list[dict[str, Any]],
@@ -921,6 +1003,10 @@ class LiteLLMProvider(LLMProvider):
kwargs["api_base"] = self.api_base
if tools:
kwargs["tools"] = [self._tool_to_openai_format(t) for t in tools]
# The Codex ChatGPT backend rejects max_output_tokens and stream_options.
if self._codex_backend:
kwargs.pop("max_tokens", None)
kwargs.pop("stream_options", None)
for attempt in range(RATE_LIMIT_MAX_RETRIES + 1):
# Post-stream events (ToolCall, TextEnd, Finish) are buffered
@@ -1801,6 +1801,21 @@ def export_graph() -> str:
mcp_servers_size = mcp_servers_path.stat().st_size
# === GIT VERSIONING ===
git_info: dict = {"initialized": False, "commit": None}
try:
from framework.utils.git import commit_all, init_repo
init_repo(exports_dir)
git_info["initialized"] = True
commit_sha = commit_all(
exports_dir,
f"Export: {session.goal.name} ({len(session.nodes)} nodes, {len(edges_list)} edges)",
)
git_info["commit"] = commit_sha
except Exception as e:
logger.warning("Git versioning skipped: %s", e)
# Get file sizes
agent_json_size = agent_json_path.stat().st_size
readme_size = readme_path.stat().st_size
@@ -1833,6 +1848,7 @@ def export_graph() -> str:
"node_count": len(session.nodes),
"edge_count": len(edges_list),
"mcp_servers_count": len(session.mcp_servers),
"git": git_info,
"note": f"Agent exported to {exports_dir}. Files: agent.json, README.md"
+ (", mcp_servers.json" if session.mcp_servers else ""),
},
+41 -15
View File
@@ -394,6 +394,11 @@ def register_commands(subparsers: argparse._SubParsersAction) -> None:
default=None,
help="LLM model for preloaded agents",
)
serve_parser.add_argument(
"--open",
action="store_true",
help="Open dashboard in browser after server starts",
)
serve_parser.set_defaults(func=cmd_serve)
@@ -834,7 +839,7 @@ def cmd_list(args: argparse.Namespace) -> int:
agents = []
for path in directory.iterdir():
if path.is_dir() and (path / "agent.json").exists():
if _is_valid_agent_dir(path):
try:
runner = AgentRunner.load(path)
info = runner.info()
@@ -901,14 +906,14 @@ def cmd_dispatch(args: argparse.Namespace) -> int:
# Use specific agents
for agent_name in args.agents:
agent_path = agents_dir / agent_name
if not (agent_path / "agent.json").exists():
if not _is_valid_agent_dir(agent_path):
print(f"Agent not found: {agent_path}", file=sys.stderr)
return 1
agent_paths.append((agent_name, agent_path))
else:
# Discover all agents
for path in agents_dir.iterdir():
if path.is_dir() and (path / "agent.json").exists():
if _is_valid_agent_dir(path):
agent_paths.append((path.name, path))
if not agent_paths:
@@ -1659,16 +1664,7 @@ def _select_agent(agents_dir: Path) -> str | None:
# Display agents for current page (with global numbering)
for i, agent_path in enumerate(page_agents, start_idx + 1):
try:
agent_json = agent_path / "agent.json"
if agent_json.exists():
with open(agent_json) as f:
data = json.load(f)
agent_meta = data.get("agent", {})
name = agent_meta.get("name", agent_path.name)
desc = agent_meta.get("description", "")
else:
# Python-based agent - extract from config.py
name, desc = _extract_python_agent_metadata(agent_path)
name, desc = _extract_python_agent_metadata(agent_path)
desc = desc[:50] + "..." if len(desc) > 50 else desc
print(f" {i}. {name}")
print(f" {desc}")
@@ -1929,6 +1925,22 @@ def cmd_setup_credentials(args: argparse.Namespace) -> int:
return 0 if result.success else 1
def _open_browser(url: str) -> None:
"""Open URL in the default browser (best-effort, non-blocking)."""
import subprocess
import sys
try:
if sys.platform == "darwin":
subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
elif sys.platform == "linux":
subprocess.Popen(
["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
except Exception:
pass # Best-effort — don't crash if browser can't open
def cmd_serve(args: argparse.Namespace) -> int:
"""Start the HTTP API server."""
import logging
@@ -1964,13 +1976,27 @@ def cmd_serve(args: argparse.Namespace) -> int:
site = web.TCPSite(runner, args.host, args.port)
await site.start()
# Check if frontend is being served
dist_candidates = [
Path("frontend/dist"),
Path("core/frontend/dist"),
]
has_frontend = any((c / "index.html").exists() for c in dist_candidates if c.is_dir())
dashboard_url = f"http://{args.host}:{args.port}"
print()
print(f"Hive API server running on http://{args.host}:{args.port}")
print(f"Health: http://{args.host}:{args.port}/api/health")
print(f"Hive API server running on {dashboard_url}")
if has_frontend:
print(f"Dashboard: {dashboard_url}")
print(f"Health: {dashboard_url}/api/health")
print(f"Agents loaded: {sum(1 for s in manager.list_sessions() if s.worker_runtime)}")
print()
print("Press Ctrl+C to stop")
# Auto-open browser if --open flag is set and frontend exists
if getattr(args, "open", False) and has_frontend:
_open_browser(dashboard_url)
# Run forever until interrupted
try:
await asyncio.Event().wait()
+323 -33
View File
@@ -5,6 +5,7 @@ import logging
import os
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -42,6 +43,13 @@ CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
# Buffer in seconds before token expiry to trigger a proactive refresh
_TOKEN_REFRESH_BUFFER_SECS = 300 # 5 minutes
# Codex (OpenAI) subscription auth
CODEX_AUTH_FILE = Path.home() / ".codex" / "auth.json"
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_KEYCHAIN_SERVICE = "Codex Auth"
_CODEX_TOKEN_LIFETIME_SECS = 3600 # 1 hour (no explicit expiry field)
def _refresh_claude_code_token(refresh_token: str) -> dict | None:
"""Refresh the Claude Code OAuth token using the refresh token.
@@ -161,6 +169,263 @@ def get_claude_code_token() -> str | None:
return access_token
# ---------------------------------------------------------------------------
# Codex (OpenAI) subscription token helpers
# ---------------------------------------------------------------------------
def _get_codex_keychain_account() -> str:
"""Compute the macOS Keychain account name used by the Codex CLI.
The Codex CLI stores credentials under the account
``cli|<sha256(~/.codex)[:16]>`` in the ``Codex Auth`` service.
"""
import hashlib
codex_dir = str(Path.home() / ".codex")
digest = hashlib.sha256(codex_dir.encode()).hexdigest()[:16]
return f"cli|{digest}"
def _read_codex_keychain() -> dict | None:
"""Read Codex auth data from macOS Keychain (macOS only).
Returns the parsed JSON from the Keychain entry, or None if not
available (wrong platform, entry missing, etc.).
"""
import platform
import subprocess
if platform.system() != "Darwin":
return None
try:
account = _get_codex_keychain_account()
result = subprocess.run(
[
"security",
"find-generic-password",
"-s",
CODEX_KEYCHAIN_SERVICE,
"-a",
account,
"-w",
],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return None
raw = result.stdout.strip()
if not raw:
return None
return json.loads(raw)
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
logger.debug("Codex keychain read failed: %s", exc)
return None
def _read_codex_auth_file() -> dict | None:
"""Read Codex auth data from ~/.codex/auth.json (fallback)."""
if not CODEX_AUTH_FILE.exists():
return None
try:
with open(CODEX_AUTH_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def _is_codex_token_expired(auth_data: dict) -> bool:
"""Check whether the Codex token is expired or close to expiry.
The Codex auth.json has no explicit ``expiresAt`` field, so we infer
expiry as ``last_refresh + _CODEX_TOKEN_LIFETIME_SECS``. Falls back
to the file mtime when ``last_refresh`` is absent.
"""
import time
from datetime import datetime
now = time.time()
last_refresh = auth_data.get("last_refresh")
if last_refresh is None:
# Fall back to file modification time
try:
last_refresh = CODEX_AUTH_FILE.stat().st_mtime
except OSError:
# Cannot determine age — assume expired
return True
elif isinstance(last_refresh, str):
# Codex stores last_refresh as an ISO 8601 timestamp string —
# convert to Unix epoch float for arithmetic.
try:
last_refresh = datetime.fromisoformat(last_refresh.replace("Z", "+00:00")).timestamp()
except (ValueError, TypeError):
return True
expires_at = last_refresh + _CODEX_TOKEN_LIFETIME_SECS
return now >= (expires_at - _TOKEN_REFRESH_BUFFER_SECS)
def _refresh_codex_token(refresh_token: str) -> dict | None:
"""Refresh the Codex OAuth token using the refresh token.
POSTs to the OpenAI auth endpoint with form-urlencoded data.
Returns:
Dict with new token data on success, None on failure.
"""
import urllib.error
import urllib.parse
import urllib.request
data = urllib.parse.urlencode(
{
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CODEX_OAUTH_CLIENT_ID,
}
).encode("utf-8")
req = urllib.request.Request(
CODEX_OAUTH_TOKEN_URL,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError) as exc:
logger.debug("Codex token refresh failed: %s", exc)
return None
def _save_refreshed_codex_credentials(auth_data: dict, token_data: dict) -> None:
"""Write refreshed tokens back to ~/.codex/auth.json only (not Keychain).
The Codex CLI manages its own Keychain entries, so we only update the
file-based credentials.
"""
from datetime import datetime
try:
tokens = auth_data.get("tokens", {})
tokens["access_token"] = token_data["access_token"]
if "refresh_token" in token_data:
tokens["refresh_token"] = token_data["refresh_token"]
if "id_token" in token_data:
tokens["id_token"] = token_data["id_token"]
auth_data["tokens"] = tokens
auth_data["last_refresh"] = datetime.now(UTC).isoformat()
CODEX_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CODEX_AUTH_FILE, "w") as f:
json.dump(auth_data, f, indent=2)
logger.debug("Codex credentials refreshed successfully")
except (OSError, KeyError) as exc:
logger.debug("Failed to save refreshed Codex credentials: %s", exc)
def get_codex_token() -> str | None:
"""Get the OAuth token from Codex subscription with auto-refresh.
Reads from macOS Keychain first, then falls back to
``~/.codex/auth.json``. If the token is expired or close to
expiry, attempts an automatic refresh.
Returns:
The access token if available, None otherwise.
"""
# Try Keychain first, then file
auth_data = _read_codex_keychain() or _read_codex_auth_file()
if not auth_data:
return None
tokens = auth_data.get("tokens", {})
access_token = tokens.get("access_token")
if not access_token:
return None
# Check if token is still valid
if not _is_codex_token_expired(auth_data):
return access_token
# Token is expired or near expiry — attempt refresh
refresh_token = tokens.get("refresh_token")
if not refresh_token:
logger.warning("Codex token expired and no refresh token available")
return access_token # Return expired token; it may still work briefly
logger.info("Codex token expired or near expiry, refreshing...")
token_data = _refresh_codex_token(refresh_token)
if token_data and "access_token" in token_data:
_save_refreshed_codex_credentials(auth_data, token_data)
return token_data["access_token"]
# Refresh failed — return the existing token and warn
logger.warning("Codex token refresh failed. Run 'codex' to re-authenticate.")
return access_token
def _get_account_id_from_jwt(access_token: str) -> str | None:
"""Extract the ChatGPT account_id from the access token JWT.
The OpenAI access token JWT contains a claim at
``https://api.openai.com/auth`` with a ``chatgpt_account_id`` field.
This is used as a fallback when the auth.json doesn't store the
account_id explicitly.
"""
import base64
try:
parts = access_token.split(".")
if len(parts) != 3:
return None
payload = parts[1]
# Add base64 padding
padding = 4 - len(payload) % 4
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
claims = json.loads(decoded)
auth = claims.get("https://api.openai.com/auth")
if isinstance(auth, dict):
account_id = auth.get("chatgpt_account_id")
if isinstance(account_id, str) and account_id:
return account_id
except Exception:
pass
return None
def get_codex_account_id() -> str | None:
"""Extract the account ID from Codex auth data for the ChatGPT-Account-Id header.
Checks the ``tokens.account_id`` field first, then falls back to
decoding the account ID from the access token JWT.
Returns:
The account_id string if available, None otherwise.
"""
auth_data = _read_codex_keychain() or _read_codex_auth_file()
if not auth_data:
return None
tokens = auth_data.get("tokens", {})
account_id = tokens.get("account_id")
if account_id:
return account_id
# Fallback: extract from JWT
access_token = tokens.get("access_token")
if access_token:
return _get_account_id_from_jwt(access_token)
return None
@dataclass
class AgentInfo:
"""Information about an exported agent."""
@@ -479,37 +744,39 @@ class AgentRunner:
def _import_agent_module(agent_path: Path):
"""Import an agent package from its directory path.
Tries package import first (works when exports/ is on sys.path,
which cli.py:_configure_paths() ensures). Falls back to direct
file import of agent.py via importlib.util.
Ensures the agent's parent directory is on sys.path so the package
can be imported normally (supports relative imports within the agent).
Always reloads the package and its submodules so that code changes
made since the last import (or since a previous session load in the
same server process) are picked up.
"""
import importlib
import sys
package_name = agent_path.name
parent_dir = str(agent_path.resolve().parent)
# Try importing as a package (works when exports/ is on sys.path)
try:
return importlib.import_module(package_name)
except ImportError:
pass
# Always place the correct parent directory first on sys.path.
# Multiple agent dirs can contain packages with the same name
# (e.g. exports/deep_research_agent and examples/deep_research_agent).
# Without this, a previously-added parent dir could shadow the
# agent we actually want to load.
if parent_dir in sys.path:
sys.path.remove(parent_dir)
sys.path.insert(0, parent_dir)
# Fallback: import agent.py directly via file path
import importlib.util
# Evict cached submodules first (e.g. deep_research_agent.nodes,
# deep_research_agent.agent) so the top-level reload picks up
# changes in the entire package — not just __init__.py.
stale = [
name for name in sys.modules
if name == package_name or name.startswith(f"{package_name}.")
]
for name in stale:
del sys.modules[name]
agent_py = agent_path / "agent.py"
if not agent_py.exists():
raise FileNotFoundError(
f"No importable agent found at {agent_path}. "
f"Expected a Python package with agent.py."
)
spec = importlib.util.spec_from_file_location(
f"{package_name}.agent",
agent_py,
submodule_search_locations=[str(agent_path)],
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
return importlib.import_module(package_name)
@classmethod
def load(
@@ -758,10 +1025,11 @@ class AgentRunner:
else:
from framework.llm.litellm import LiteLLMProvider
# Check if Claude Code subscription is configured
# Check if a subscription mode is configured
config = get_hive_config()
llm_config = config.get("llm", {})
use_claude_code = llm_config.get("use_claude_code_subscription", False)
use_codex = llm_config.get("use_codex_subscription", False)
api_base = llm_config.get("api_base")
api_key = None
@@ -771,6 +1039,12 @@ class AgentRunner:
if not api_key:
print("Warning: Claude Code subscription configured but no token found.")
print("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.")
if api_key and use_claude_code:
# Use litellm's built-in Anthropic OAuth support.
@@ -782,6 +1056,25 @@ class AgentRunner:
api_base=api_base,
extra_headers={"authorization": f"Bearer {api_key}"},
)
elif api_key and use_codex:
# OpenAI Codex subscription routes through the ChatGPT backend
# (chatgpt.com/backend-api/codex/responses), NOT the standard
# OpenAI API. The consumer OAuth token lacks platform API scopes.
extra_headers: dict[str, str] = {
"Authorization": f"Bearer {api_key}",
"User-Agent": "CodexBar",
}
account_id = get_codex_account_id()
if account_id:
extra_headers["ChatGPT-Account-Id"] = account_id
self._llm = LiteLLMProvider(
model=self.model,
api_key=api_key,
api_base="https://chatgpt.com/backend-api/codex",
extra_headers=extra_headers,
store=False,
allowed_openai_params=["store"],
)
else:
# Local models (e.g. Ollama) don't need an API key
if self._is_local_model(self.model):
@@ -995,17 +1288,14 @@ class AgentRunner:
async_checkpoint=True, # Non-blocking
)
# Handle runtime_config - ensure it's AgentRuntimeConfig, not RuntimeConfig
# RuntimeConfig is for LLM settings; AgentRuntimeConfig is for AgentRuntime settings
# Handle runtime_config - only pass through if it's actually an AgentRuntimeConfig.
# Agents may export a RuntimeConfig (LLM settings) or queen-generated custom classes
# that would crash AgentRuntime if passed through.
runtime_config = None
if self.runtime_config is not None:
from framework.config import RuntimeConfig
from framework.runtime.agent_runtime import AgentRuntimeConfig
# If it's a RuntimeConfig (LLM config), don't pass it
if isinstance(self.runtime_config, RuntimeConfig):
runtime_config = None
else:
# It's already an AgentRuntimeConfig or compatible type
if isinstance(self.runtime_config, AgentRuntimeConfig):
runtime_config = self.runtime_config
self._agent_runtime = create_agent_runtime(
+134
View File
@@ -209,6 +209,7 @@ class AgentRuntime:
# State
self._running = False
self._timers_paused = False
self._lock = asyncio.Lock()
# Optional greeting shown to user on TUI load (set by AgentRunner)
@@ -424,6 +425,36 @@ class AgentRuntime:
)
await asyncio.sleep(max(0, sleep_secs))
while self._running:
# Calculate next fire time upfront (used by skip paths too)
cron = croniter(expr, datetime.now())
next_dt = cron.get_next(datetime)
sleep_secs = (next_dt - datetime.now()).total_seconds()
# Gate: skip tick if timers are explicitly paused
if self._timers_paused:
logger.debug(
"Cron '%s': paused, skipping tick",
entry_point_id,
)
self._timer_next_fire[entry_point_id] = (
time.monotonic() + sleep_secs
)
await asyncio.sleep(max(0, sleep_secs))
continue
# Gate: skip tick if previous execution still running
_stream = self._streams.get(entry_point_id)
if _stream and _stream.active_execution_ids:
logger.debug(
"Cron '%s': execution already in progress, skipping tick",
entry_point_id,
)
self._timer_next_fire[entry_point_id] = (
time.monotonic() + sleep_secs
)
await asyncio.sleep(max(0, sleep_secs))
continue
self._timer_next_fire.pop(entry_point_id, None)
try:
ep_spec = self._entry_points.get(entry_point_id)
@@ -439,6 +470,18 @@ class AgentRuntime:
session_state = self._get_primary_session_state(
exclude_entry_point=entry_point_id
)
# Gate: skip tick if no active session
if session_state is None:
logger.debug(
"Cron '%s': no active session, skipping",
entry_point_id,
)
self._timer_next_fire[entry_point_id] = (
time.monotonic() + sleep_secs
)
await asyncio.sleep(max(0, sleep_secs))
continue
exec_id = await self.trigger(
entry_point_id,
{
@@ -496,6 +539,31 @@ class AgentRuntime:
)
await asyncio.sleep(interval_secs)
while self._running:
# Gate: skip tick if timers are explicitly paused
if self._timers_paused:
logger.debug(
"Timer '%s': paused, skipping tick",
entry_point_id,
)
self._timer_next_fire[entry_point_id] = (
time.monotonic() + interval_secs
)
await asyncio.sleep(interval_secs)
continue
# Gate: skip tick if previous execution still running
_stream = self._streams.get(entry_point_id)
if _stream and _stream.active_execution_ids:
logger.debug(
"Timer '%s': execution already in progress, skipping tick",
entry_point_id,
)
self._timer_next_fire[entry_point_id] = (
time.monotonic() + interval_secs
)
await asyncio.sleep(interval_secs)
continue
self._timer_next_fire.pop(entry_point_id, None)
try:
ep_spec = self._entry_points.get(entry_point_id)
@@ -511,6 +579,18 @@ class AgentRuntime:
session_state = self._get_primary_session_state(
exclude_entry_point=entry_point_id
)
# Gate: skip tick if no active session
if session_state is None:
logger.debug(
"Timer '%s': no active session, skipping",
entry_point_id,
)
self._timer_next_fire[entry_point_id] = (
time.monotonic() + interval_secs
)
await asyncio.sleep(interval_secs)
continue
exec_id = await self.trigger(
entry_point_id,
{
@@ -570,6 +650,7 @@ class AgentRuntime:
)
self._running = True
self._timers_paused = False
logger.info(f"AgentRuntime started with {len(self._streams)} streams")
async def stop(self) -> None:
@@ -611,6 +692,19 @@ class AgentRuntime:
self._running = False
logger.info("AgentRuntime stopped")
def pause_timers(self) -> None:
"""Pause all timer-driven entry points.
Timers will skip their ticks until ``resume_timers()`` is called.
"""
self._timers_paused = True
logger.info("Timers paused")
def resume_timers(self) -> None:
"""Resume timer-driven entry points after a pause."""
self._timers_paused = False
logger.info("Timers resumed")
def _resolve_stream(
self,
entry_point_id: str,
@@ -885,6 +979,30 @@ class AgentRuntime:
timer_next_fire[local_ep] = time.monotonic() + interval_secs
await asyncio.sleep(interval_secs)
while self._running and gid in self._graphs:
# Gate: skip tick if timers are explicitly paused
if self._timers_paused:
logger.debug(
"Timer '%s::%s': paused, skipping tick",
gid,
local_ep,
)
timer_next_fire[local_ep] = time.monotonic() + interval_secs
await asyncio.sleep(interval_secs)
continue
# Gate: skip tick if previous execution still running
_reg = self._graphs.get(gid)
_stream = _reg.streams.get(local_ep) if _reg else None
if _stream and _stream.active_execution_ids:
logger.debug(
"Timer '%s::%s': execution already in progress, skipping tick",
gid,
local_ep,
)
timer_next_fire[local_ep] = time.monotonic() + interval_secs
await asyncio.sleep(interval_secs)
continue
logger.info("Timer firing for '%s::%s'", gid, local_ep)
timer_next_fire.pop(local_ep, None)
try:
@@ -912,6 +1030,17 @@ class AgentRuntime:
session_state = self._get_primary_session_state(
local_ep, source_graph_id=gid
)
# Gate: skip tick if no active session
if session_state is None:
logger.debug(
"Timer '%s::%s': no active session, skipping",
gid,
local_ep,
)
timer_next_fire[local_ep] = time.monotonic() + interval_secs
await asyncio.sleep(interval_secs)
continue
exec_id = await stream.execute(
{"event": {"source": "timer", "reason": "scheduled"}},
session_state=session_state,
@@ -1387,6 +1516,11 @@ class AgentRuntime:
"""Access the webhook server (None if no webhook entry points)."""
return self._webhook_server
@property
def timers_paused(self) -> bool:
"""True when timer-driven entry points are paused (e.g. by stop_worker)."""
return self._timers_paused
@property
def is_running(self) -> bool:
"""Check if runtime is running."""
+3
View File
@@ -129,6 +129,9 @@ class EventType(StrEnum):
WORKER_ESCALATION_TICKET = "worker_escalation_ticket"
QUEEN_INTERVENTION_REQUESTED = "queen_intervention_requested"
# Worker lifecycle (session manager → frontend)
WORKER_LOADED = "worker_loaded"
@dataclass
class AgentEvent:
+2 -1
View File
@@ -570,6 +570,7 @@ class ExecutionStream:
await self._write_session_state(execution_id, ctx, result=result)
# Emit completion/failure event
# (skip for pauses — executor already emitted execution_paused)
if self._scoped_event_bus:
if result.success:
await self._scoped_event_bus.emit_execution_completed(
@@ -578,7 +579,7 @@ class ExecutionStream:
output=result.output,
correlation_id=ctx.correlation_id,
)
else:
elif not result.paused_at:
await self._scoped_event_bus.emit_execution_failed(
stream_id=self.stream_id,
execution_id=execution_id,
+284 -72
View File
@@ -2,20 +2,33 @@
HTTP API backend for the Hive agent framework. Built on **aiohttp**, fully async, serving the frontend workspace and external clients.
## Architecture
Sessions are the primary entity. A session owns an EventBus + LLM and always has a queen executor. Workers are optional — they can be loaded into and unloaded from a session at any time.
```
Session {
event_bus # owned by session, shared with queen + worker
llm # owned by session
queen_executor # always present
worker_runtime? # optional — loaded/unloaded independently
}
```
## Structure
```
server/
├── app.py # Application factory, middleware, static serving
├── agent_manager.py # Agent lifecycle (load/unload/monitor)
├── session_manager.py # Session lifecycle (create/load worker/unload/stop)
├── sse.py # Server-Sent Events helper
├── routes_agents.py # Agent CRUD & discovery
├── routes_credentials.py # Credential management
├── routes_execution.py # Trigger, inject, chat, pause, resume, replay
├── routes_sessions.py # Session lifecycle, info, worker-session browsing, discovery
├── routes_execution.py # Trigger, inject, chat, stop, resume, replay
├── routes_events.py # SSE event streaming
├── routes_graphs.py # Graph topology & node inspection
├── routes_logs.py # Execution logs (summary/details/tools)
├── routes_sessions.py # Session browsing & restore
├── routes_credentials.py # Credential management & validation
├── routes_agents.py # Legacy backward-compat routes
└── tests/
└── test_api.py # Full test suite with mocked runtimes
```
@@ -29,106 +42,305 @@ server/
- **CORS middleware** — allows localhost origins
- **Error middleware** — catches exceptions, returns JSON errors
- **Static serving** — serves the frontend SPA with index.html fallback
- **Graceful shutdown** — unloads all agents on exit
- **Graceful shutdown** — stops all sessions on exit
### `agent_manager.py` — Agent Lifecycle Manager
### `session_manager.py` — Session Lifecycle Manager
Manages `AgentSlot` objects — each holding a loaded agent's runtime resources:
Manages `Session` objects. Key methods:
- **`load_agent()`** — loads agent from disk, sets up runtime, starts queen + judge
- **`unload_agent()`** — cancels monitoring tasks, tears down runtime
- **Three-conversation model**:
1. **Worker**the existing `AgentRuntime` that executes graphs
2. **Queen**persistent interactive executor for user chat
3. **Judge** — timer-driven background executor for health monitoring (fires every 2 min)
- **`create_session()`** — creates EventBus + LLM, starts queen (no worker)
- **`create_session_with_worker()`** — one-step: session + worker + judge
- **`load_worker()`** — loads agent into existing session, starts judge
- **`unload_worker()`** — removes worker + judge, queen stays alive
- **`stop_session()`** — tears down everything (worker + queen)
Three-conversation model:
1. **Queen** — persistent interactive executor for user chat (always present)
2. **Worker**`AgentRuntime` that executes graphs (optional)
3. **Judge** — timer-driven background executor for health monitoring (active when worker is loaded)
### `sse.py` — SSE Helper
Thin wrapper around `aiohttp.StreamResponse` for Server-Sent Events. Used by `routes_events.py` to stream runtime events to clients with keepalive pings.
Thin wrapper around `aiohttp.StreamResponse` for Server-Sent Events with keepalive pings.
## API Routes
## API Reference
### Agents — `/api/agents`
All session-scoped routes use the `session_id` returned from `POST /api/sessions`.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/discover` | Discover agents from filesystem |
| GET | `/api/agents` | List loaded agents |
| POST | `/api/agents` | Load agent (body: `agent_path`, `agent_id`, `model`) |
| GET | `/api/agents/{agent_id}` | Agent details + entry points + graphs |
| DELETE | `/api/agents/{agent_id}` | Unload agent |
| GET | `/api/agents/{agent_id}/stats` | Runtime statistics |
### Discovery
### Execution — `/api/agents/{agent_id}/...`
| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/discover` | Discover agents from filesystem |
| Method | Path | Description |
|--------|------|-------------|
| POST | `.../trigger` | Start new execution |
| POST | `.../inject` | Inject input to a waiting node |
| POST | `.../chat` | Smart routing: inject to worker/queen or trigger new |
| POST | `.../stop` | Cancel execution (saves as paused, resumable) |
| POST | `.../pause` | Alias for stop |
| POST | `.../resume` | Resume from session state or checkpoint |
| POST | `.../replay` | Re-run from a checkpoint |
| GET | `.../goal-progress` | Evaluate goal progress |
Returns agents grouped by category with metadata (name, description, node count, tags, etc.).
### Events — SSE Streaming
### Session Lifecycle
| Method | Path | Description |
|--------|------|-------------|
| GET | `.../events` | SSE stream (query: `types` for filtering) |
| Method | Route | Description |
|--------|-------|-------------|
| `POST` | `/api/sessions` | Create a session |
| `GET` | `/api/sessions` | List all active sessions |
| `GET` | `/api/sessions/{session_id}` | Session detail (includes entry points + graphs if worker loaded) |
| `DELETE` | `/api/sessions/{session_id}` | Stop session entirely |
Default event types include: `CLIENT_OUTPUT_DELTA`, `CLIENT_INPUT_REQUESTED`, `EXECUTION_STARTED/COMPLETED/FAILED/PAUSED`, `NODE_LOOP_*`, `EDGE_TRAVERSED`, `GOAL_PROGRESS`, `QUEEN_INTERVENTION_REQUESTED`, and more.
**Create session** has two modes:
### Graphs — Node Inspection
```jsonc
// Queen-only session (no worker)
POST /api/sessions
{}
// or with custom ID:
{ "session_id": "my-custom-id" }
| Method | Path | Description |
|--------|------|-------------|
| GET | `.../graphs/{graph_id}/nodes` | List nodes (optional session enrichment) |
| GET | `.../graphs/{graph_id}/nodes/{node_id}` | Node detail + outgoing edges |
| GET | `.../graphs/{graph_id}/nodes/{node_id}/criteria` | Success criteria + judge verdicts |
| GET | `.../graphs/{graph_id}/nodes/{node_id}/tools` | Resolved tool metadata |
// Session with worker (one-step)
POST /api/sessions
{
"agent_path": "exports/my-agent",
"agent_id": "custom-worker-name", // optional
"model": "claude-sonnet-4-20250514" // optional
}
```
### Sessions
- Returns `201` with session object on success
- Returns `409` with `{"loading": true}` if agent is currently loading
- Returns `404` if agent_path doesn't exist
| Method | Path | Description |
|--------|------|-------------|
| GET | `.../sessions` | List all sessions |
| GET | `.../sessions/{session_id}` | Full session state |
| DELETE | `.../sessions/{session_id}` | Delete session |
| GET | `.../sessions/{session_id}/checkpoints` | List checkpoints |
| POST | `.../sessions/{session_id}/checkpoints/{checkpoint_id}/restore` | Restore checkpoint |
| GET | `.../sessions/{session_id}/messages` | Chat history (query: `node_id`, `client_only`) |
**Get session** returns `202` with `{"loading": true}` while loading, `404` if not found.
### Worker Lifecycle
| Method | Route | Description |
|--------|-------|-------------|
| `POST` | `/api/sessions/{session_id}/worker` | Load a worker into session |
| `DELETE` | `/api/sessions/{session_id}/worker` | Unload worker (queen stays alive) |
```jsonc
// Load worker into existing session
POST /api/sessions/{session_id}/worker
{
"agent_path": "exports/my-agent",
"worker_id": "custom-name", // optional
"model": "..." // optional
}
// Unload worker
DELETE /api/sessions/{session_id}/worker
```
### Execution Control
| Method | Route | Description |
|--------|-------|-------------|
| `POST` | `/api/sessions/{session_id}/trigger` | Start a new execution |
| `POST` | `/api/sessions/{session_id}/inject` | Inject input into a waiting node |
| `POST` | `/api/sessions/{session_id}/chat` | Smart chat routing |
| `POST` | `/api/sessions/{session_id}/stop` | Cancel a running execution |
| `POST` | `/api/sessions/{session_id}/pause` | Alias for stop |
| `POST` | `/api/sessions/{session_id}/resume` | Resume a paused execution |
| `POST` | `/api/sessions/{session_id}/replay` | Re-run from a checkpoint |
| `GET` | `/api/sessions/{session_id}/goal-progress` | Evaluate goal progress |
**Trigger:**
```jsonc
POST /api/sessions/{session_id}/trigger
{
"entry_point_id": "default",
"input_data": { "query": "research topic X" },
"session_state": {} // optional
}
// Returns: { "execution_id": "..." }
```
**Chat** routes messages with priority:
1. Worker awaiting input -> inject into worker node
2. Queen active -> inject into queen conversation
3. Neither available -> 503
```jsonc
POST /api/sessions/{session_id}/chat
{ "message": "hello" }
// Returns: { "status": "injected"|"queen", "delivered": true }
```
**Inject** into a specific node:
```jsonc
POST /api/sessions/{session_id}/inject
{ "node_id": "gather_info", "content": "user response", "graph_id": "main" }
```
**Stop:**
```jsonc
POST /api/sessions/{session_id}/stop
{ "execution_id": "..." }
```
**Resume:**
```jsonc
POST /api/sessions/{session_id}/resume
{
"session_id": "session_20260224_...", // worker session to resume
"checkpoint_id": "cp_..." // optional — resumes from latest if omitted
}
```
**Replay** (re-run from checkpoint):
```jsonc
POST /api/sessions/{session_id}/replay
{
"session_id": "session_20260224_...",
"checkpoint_id": "cp_..." // required
}
```
### SSE Event Streaming
| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/sessions/{session_id}/events` | SSE event stream |
```
GET /api/sessions/{session_id}/events
GET /api/sessions/{session_id}/events?types=CLIENT_OUTPUT_DELTA,EXECUTION_COMPLETED
```
Keepalive ping every 15s. Streams from the session's EventBus (covers both queen and worker events).
Default event types: `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_ITERATION`, `NODE_LOOP_COMPLETED`, `NODE_ACTION_PLAN`, `EDGE_TRAVERSED`, `GOAL_PROGRESS`, `QUEEN_INTERVENTION_REQUESTED`, `WORKER_ESCALATION_TICKET`, `NODE_INTERNAL_OUTPUT`, `NODE_STALLED`, `NODE_RETRY`, `NODE_TOOL_DOOM_LOOP`, `CONTEXT_COMPACTED`, `WORKER_LOADED`.
### Session Info
| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/sessions/{session_id}/stats` | Runtime statistics |
| `GET` | `/api/sessions/{session_id}/entry-points` | List entry points |
| `GET` | `/api/sessions/{session_id}/graphs` | List loaded graph IDs |
### Graph & Node Inspection
| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/sessions/{session_id}/graphs/{graph_id}/nodes` | List nodes + edges |
| `GET` | `/api/sessions/{session_id}/graphs/{graph_id}/nodes/{node_id}` | Node detail + outgoing edges |
| `GET` | `/api/sessions/{session_id}/graphs/{graph_id}/nodes/{node_id}/criteria` | Success criteria + last execution info |
| `GET` | `/api/sessions/{session_id}/graphs/{graph_id}/nodes/{node_id}/tools` | Resolved tool metadata |
**List nodes** supports optional enrichment with session progress:
```
GET /api/sessions/{session_id}/graphs/{graph_id}/nodes?session_id=worker_session_id
```
Adds `visit_count`, `has_failures`, `is_current`, `in_path` to each node.
### Logs
| Method | Path | Description |
|--------|------|-------------|
| GET | `.../logs` | Agent-level logs (query: `session_id`, `level`, `limit`) |
| GET | `.../graphs/{graph_id}/nodes/{node_id}/logs` | Node-scoped logs |
| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/sessions/{session_id}/logs` | Session-level logs |
| `GET` | `/api/sessions/{session_id}/graphs/{graph_id}/nodes/{node_id}/logs` | Node-scoped logs |
```
# List recent runs
GET /api/sessions/{session_id}/logs?level=summary&limit=20
# Detailed per-node execution for a specific worker session
GET /api/sessions/{session_id}/logs?session_id=ws_id&level=details
# Tool call logs
GET /api/sessions/{session_id}/logs?session_id=ws_id&level=tools
# Node-scoped (requires session_id query param)
GET .../nodes/{node_id}/logs?session_id=ws_id&level=all
```
Log levels: `summary` (run stats), `details` (per-node execution), `tools` (tool calls + LLM text).
### Worker Session Browsing
Browse persisted execution runs on disk.
| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/sessions/{session_id}/worker-sessions` | List worker sessions |
| `GET` | `/api/sessions/{session_id}/worker-sessions/{ws_id}` | Worker session state |
| `DELETE` | `/api/sessions/{session_id}/worker-sessions/{ws_id}` | Delete worker session |
| `GET` | `/api/sessions/{session_id}/worker-sessions/{ws_id}/checkpoints` | List checkpoints |
| `POST` | `/api/sessions/{session_id}/worker-sessions/{ws_id}/checkpoints/{cp_id}/restore` | Restore from checkpoint |
| `GET` | `/api/sessions/{session_id}/worker-sessions/{ws_id}/messages` | Get conversation messages |
**Messages** support filtering:
```
GET .../messages?node_id=gather_info # filter by node
GET .../messages?client_only=true # only user inputs + client-facing assistant outputs
```
### Credentials
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/credentials` | List credential metadata (no secrets) |
| POST | `/api/credentials` | Save credential |
| GET | `/api/credentials/{credential_id}` | Get credential metadata |
| DELETE | `/api/credentials/{credential_id}` | Delete credential |
| POST | `/api/credentials/check-agent` | Check which credentials an agent needs |
| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/credentials` | List credential metadata (no secrets) |
| `POST` | `/api/credentials` | Save a credential |
| `GET` | `/api/credentials/{credential_id}` | Get credential metadata |
| `DELETE` | `/api/credentials/{credential_id}` | Delete a credential |
| `POST` | `/api/credentials/check-agent` | Validate agent credentials |
**Save credential:**
```jsonc
POST /api/credentials
{ "credential_id": "brave_search", "keys": { "api_key": "BSA..." } }
```
**Check agent credentials** — two-phase validation (same as runtime startup):
```jsonc
POST /api/credentials/check-agent
{
"agent_path": "exports/my-agent",
"verify": true // optional, default true — run health checks
}
// Returns:
{
"required": [
{
"credential_name": "brave_search",
"credential_id": "brave_search",
"env_var": "BRAVE_SEARCH_API_KEY",
"description": "Brave Search API key",
"help_url": "https://...",
"tools": ["brave_web_search"],
"node_types": [],
"available": true,
"valid": true, // true/false/null (null = not checked)
"validation_message": "OK", // human-readable health check result
"direct_api_key_supported": true,
"aden_supported": true,
"credential_key": "api_key"
}
]
}
```
When `verify: true`, runs health checks (lightweight HTTP calls) against each available credential to confirm it actually works — not just that it exists.
## Key Patterns
- **Per-request manager access** — routes get `AgentManager` via `request.app["manager"]`
- **Path validation** — all user-provided path segments validated with `safe_path_segment()` to prevent directory traversal
- **Session-primary** — sessions are the lookup key for all routes, workers are optional children
- **Per-request manager access** — routes get `SessionManager` via `request.app["manager"]`
- **Path validation** — user-provided path segments validated with `safe_path_segment()` to prevent directory traversal
- **Event-driven streaming** — per-client buffer queues (max 1000 events) with 15s keepalive pings
- **Session persistence** — executions saved to `~/.hive/agents/{agent_name}/sessions/`
- **Shared EventBus** — session owns the bus, queen and worker both publish to it, SSE always connects to `session.event_bus`
- **No secrets in responses** — credential endpoints never return secret values
## Storage Paths
```
~/.hive/
├── queen/session/{session_id}/ # Queen conversation state
├── judge/session/{session_id}/ # Judge state
├── agents/{agent_name}/sessions/ # Worker execution sessions
└── credentials/ # Encrypted credential store
```
## Running Tests
```bash
pytest core/framework/server/tests/ -v
pytest framework/server/tests/ -v
```
+15 -2
View File
@@ -22,6 +22,19 @@ def safe_path_segment(value: str) -> str:
return value
def resolve_session(request: web.Request):
"""Resolve a Session from {session_id} in the URL.
Returns (session, None) on success or (None, error_response) on failure.
"""
manager: SessionManager = request.app["manager"]
sid = request.match_info["session_id"]
session = manager.get_session(sid)
if not session:
return None, web.json_response({"error": f"Session '{sid}' not found"}, status=404)
return session, None
def sessions_dir(session: Session) -> Path:
"""Resolve the worker sessions directory for a session.
@@ -138,21 +151,21 @@ def create_app(model: str | None = None) -> web.Application:
app.router.add_get("/api/health", handle_health)
# Register route modules
from framework.server.routes_agents import register_routes as register_agent_routes
from framework.server.routes_credentials import register_routes as register_credential_routes
from framework.server.routes_events import register_routes as register_event_routes
from framework.server.routes_execution import register_routes as register_execution_routes
from framework.server.routes_graphs import register_routes as register_graph_routes
from framework.server.routes_logs import register_routes as register_log_routes
from framework.server.routes_sessions import register_routes as register_session_routes
from framework.server.routes_versions import register_routes as register_version_routes
register_credential_routes(app)
register_agent_routes(app)
register_execution_routes(app)
register_event_routes(app)
register_session_routes(app)
register_graph_routes(app)
register_log_routes(app)
register_version_routes(app)
# Static file serving — Option C production mode
# If frontend/dist/ exists, serve built frontend files on /
-258
View File
@@ -1,258 +0,0 @@
"""Agent CRUD and discovery routes.
These routes provide backward compatibility with the frontend which addresses
agents by ID. Internally, all state is managed via SessionManager sessions.
"""
import logging
import time
from aiohttp import web
from framework.server.session_manager import Session, SessionManager
logger = logging.getLogger(__name__)
def _get_manager(request: web.Request) -> SessionManager:
return request.app["manager"]
def _session_to_agent_dict(session: Session) -> dict:
"""Serialize a Session to the legacy agent JSON shape.
The frontend expects agent responses with id, name, description, etc.
This maps Session fields to that format for backward compat.
"""
info = session.worker_info
# Use worker_id if available (backward compat), otherwise session id
agent_id = session.worker_id or session.id
return {
"id": agent_id,
"agent_path": str(session.worker_path) if session.worker_path else "",
"name": info.name if info else agent_id,
"description": info.description if info else "",
"goal": info.goal_name if info else "",
"node_count": info.node_count if info else 0,
"loaded_at": session.loaded_at,
"uptime_seconds": round(time.time() - session.loaded_at, 1),
"intro_message": getattr(session.runner, "intro_message", "") or "",
"has_worker": session.worker_runtime is not None,
"session_id": session.id,
}
async def handle_queen_session(request: web.Request) -> web.Response:
"""POST /api/sessions/queen — start a queen-only session."""
manager = _get_manager(request)
body = await request.json() if request.can_read_body else {}
model = body.get("model")
session_id = body.get("session_id")
try:
session = await manager.create_session(session_id=session_id, model=model)
except ValueError as e:
return web.json_response({"error": str(e)}, status=409)
except Exception as e:
logger.exception(f"Error starting queen session: {e}")
return web.json_response({"error": str(e)}, status=500)
return web.json_response(_session_to_agent_dict(session), status=201)
async def handle_discover(request: web.Request) -> web.Response:
"""GET /api/discover — discover agents from filesystem."""
from framework.tui.screens.agent_picker import discover_agents
manager = _get_manager(request)
loaded_paths = {
str(s.worker_path) for s in manager.list_sessions() if s.worker_path
}
groups = discover_agents()
result = {}
for category, entries in groups.items():
result[category] = [
{
"path": str(entry.path),
"name": entry.name,
"description": entry.description,
"category": entry.category,
"session_count": entry.session_count,
"node_count": entry.node_count,
"tool_count": entry.tool_count,
"tags": entry.tags,
"last_active": entry.last_active,
"is_loaded": str(entry.path) in loaded_paths,
}
for entry in entries
]
return web.json_response(result)
async def handle_list_agents(request: web.Request) -> web.Response:
"""GET /api/agents — list all loaded agents (backward compat).
Returns sessions that have a worker loaded.
"""
manager = _get_manager(request)
agents = [
_session_to_agent_dict(s)
for s in manager.list_sessions()
if s.worker_runtime is not None
]
return web.json_response({"agents": agents})
async def handle_load_agent(request: web.Request) -> web.Response:
"""POST /api/agents — load an agent from disk (backward compat).
Creates a session with a worker in one step.
Body: {"agent_path": "...", "agent_id": "...", "model": "..."}
"""
manager = _get_manager(request)
body = await request.json()
agent_path = body.get("agent_path")
if not agent_path:
return web.json_response({"error": "agent_path is required"}, status=400)
agent_id = body.get("agent_id")
model = body.get("model")
try:
session = await manager.create_session_with_worker(
agent_path, agent_id=agent_id, model=model,
)
except ValueError as e:
from pathlib import Path
resolved_id = agent_id or Path(agent_path).name
msg = str(e)
if "currently loading" in msg:
return web.json_response(
{"error": msg, "id": resolved_id, "loading": True},
status=409,
)
existing = manager.get_session_for_agent(resolved_id)
if existing:
return web.json_response(
{"error": msg, **_session_to_agent_dict(existing)},
status=409,
)
return web.json_response({"error": msg}, status=409)
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except Exception as e:
logger.exception(f"Error loading agent: {e}")
return web.json_response({"error": str(e)}, status=500)
return web.json_response(_session_to_agent_dict(session), status=201)
async def handle_get_agent(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id} — get agent details."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
if manager.is_loading(agent_id):
return web.json_response({"id": agent_id, "loading": True}, status=202)
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
data = _session_to_agent_dict(session)
if session.worker_runtime:
data["entry_points"] = [
{
"id": ep.id,
"name": ep.name,
"entry_node": ep.entry_node,
"trigger_type": ep.trigger_type,
}
for ep in session.worker_runtime.get_entry_points()
]
data["graphs"] = session.worker_runtime.list_graphs()
return web.json_response(data)
async def handle_unload_agent(request: web.Request) -> web.Response:
"""DELETE /api/agents/{agent_id} — unload an agent (stops entire session)."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
# Find the session for this agent
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
await manager.stop_session(session.id)
return web.json_response({"unloaded": agent_id})
async def handle_stats(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/stats — runtime statistics."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
stats = session.worker_runtime.get_stats() if session.worker_runtime else {}
return web.json_response(stats)
async def handle_entry_points(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/entry-points — list entry points."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
eps = session.worker_runtime.get_entry_points() if session.worker_runtime else []
return web.json_response(
{
"entry_points": [
{
"id": ep.id,
"name": ep.name,
"entry_node": ep.entry_node,
"trigger_type": ep.trigger_type,
}
for ep in eps
]
}
)
async def handle_graphs(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/graphs — list loaded graphs."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
graphs = session.worker_runtime.list_graphs() if session.worker_runtime else []
return web.json_response({"graphs": graphs})
def register_routes(app: web.Application) -> None:
"""Register agent CRUD routes on the application."""
app.router.add_post("/api/sessions/queen", handle_queen_session)
app.router.add_get("/api/discover", handle_discover)
app.router.add_get("/api/agents", handle_list_agents)
app.router.add_post("/api/agents", handle_load_agent)
app.router.add_get("/api/agents/{agent_id}", handle_get_agent)
app.router.add_delete("/api/agents/{agent_id}", handle_unload_agent)
app.router.add_get("/api/agents/{agent_id}/stats", handle_stats)
app.router.add_get("/api/agents/{agent_id}/entry-points", handle_entry_points)
app.router.add_get("/api/agents/{agent_id}/graphs", handle_graphs)
+86 -27
View File
@@ -81,51 +81,110 @@ async def handle_delete_credential(request: web.Request) -> web.Response:
async def handle_check_agent(request: web.Request) -> web.Response:
"""POST /api/credentials/check-agent — check which credentials an agent needs.
"""POST /api/credentials/check-agent — check and validate agent credentials.
Body: {"agent_path": "..."}
Uses the same ``validate_agent_credentials`` as agent startup:
1. Presence is the credential available (env, encrypted store, Aden)?
2. Health check does the credential actually work (lightweight HTTP call)?
Body: {"agent_path": "...", "verify": true}
"""
store = _get_store(request)
body = await request.json()
agent_path = body.get("agent_path")
verify = body.get("verify", True)
if not agent_path:
return web.json_response({"error": "agent_path is required"}, status=400)
try:
from framework.credentials.setup import (
CredentialSetupSession,
)
from framework.credentials.setup import load_agent_nodes
from framework.credentials.validation import ensure_credential_key_env, validate_agent_credentials
session = CredentialSetupSession.from_agent_path(agent_path, missing_only=False)
required = []
for mc in session.missing:
cred_id = mc.credential_id or mc.credential_name
required.append(
{
"credential_name": mc.credential_name,
"credential_id": cred_id,
"env_var": mc.env_var,
"description": mc.description,
"help_url": mc.help_url,
"tools": mc.tools,
"node_types": mc.node_types,
"available": store.is_available(cred_id),
"direct_api_key_supported": mc.direct_api_key_supported,
"aden_supported": mc.aden_supported,
"credential_key": mc.credential_key,
}
)
return web.json_response({"required": required})
# Load env vars from shell config (same as runtime startup)
ensure_credential_key_env()
nodes = load_agent_nodes(agent_path)
result = validate_agent_credentials(nodes, verify=verify, raise_on_error=False)
required = [_status_to_dict(c) for c in result.credentials]
return web.json_response(
{
"required": required,
"has_aden_key": result.has_aden_key,
}
)
except Exception as e:
logger.exception(f"Error checking agent credentials: {e}")
return web.json_response({"error": str(e)}, status=500)
def _status_to_dict(c) -> dict:
"""Convert a CredentialStatus to the JSON dict expected by the frontend."""
return {
"credential_name": c.credential_name,
"credential_id": c.credential_id,
"env_var": c.env_var,
"description": c.description,
"help_url": c.help_url,
"tools": c.tools,
"node_types": c.node_types,
"available": c.available,
"direct_api_key_supported": c.direct_api_key_supported,
"aden_supported": c.aden_supported,
"credential_key": c.credential_key,
"valid": c.valid,
"validation_message": c.validation_message,
}
async def handle_save_aden_key(request: web.Request) -> web.Response:
"""POST /api/credentials/aden-key — save the user's ADEN_API_KEY.
Sets the key in the current process environment and persists it to shell
config so future terminals pick it up. Then triggers an Aden token sync
so OAuth credentials resolve immediately.
Body: {"key": "..."}
"""
import os
body = await request.json()
key = body.get("key", "").strip()
if not key:
return web.json_response({"error": "key is required"}, status=400)
os.environ["ADEN_API_KEY"] = key
# Persist to shell config (best-effort, same pattern as TUI setup)
try:
from aden_tools.credentials.shell_config import add_env_var_to_shell_config
add_env_var_to_shell_config(
"ADEN_API_KEY",
key,
comment="Aden Platform API key",
)
except Exception as exc:
logger.warning("Could not persist ADEN_API_KEY to shell config: %s", exc)
# Immediately sync OAuth tokens from Aden
try:
from aden_tools.credentials import CREDENTIAL_SPECS
from framework.credentials.validation import _presync_aden_tokens
_presync_aden_tokens(CREDENTIAL_SPECS)
except Exception as exc:
logger.warning("Aden token sync after key save failed: %s", exc)
return web.json_response({"saved": True}, status=201)
def register_routes(app: web.Application) -> None:
"""Register credential routes on the application."""
# check-agent must be registered BEFORE the {credential_id} wildcard
# check-agent and aden-key must be registered BEFORE the {credential_id} wildcard
app.router.add_post("/api/credentials/check-agent", handle_check_agent)
app.router.add_post("/api/credentials/aden-key", handle_save_aden_key)
app.router.add_get("/api/credentials", handle_list_credentials)
app.router.add_post("/api/credentials", handle_save_credential)
app.router.add_get("/api/credentials/{credential_id}", handle_get_credential)
+15 -11
View File
@@ -6,7 +6,7 @@ import logging
from aiohttp import web
from framework.runtime.event_bus import EventType
from framework.server.session_manager import SessionManager
from framework.server.app import resolve_session
logger = logging.getLogger(__name__)
@@ -29,6 +29,12 @@ DEFAULT_EVENT_TYPES = [
EventType.GOAL_PROGRESS,
EventType.QUEEN_INTERVENTION_REQUESTED,
EventType.WORKER_ESCALATION_TICKET,
EventType.NODE_INTERNAL_OUTPUT,
EventType.NODE_STALLED,
EventType.NODE_RETRY,
EventType.NODE_TOOL_DOOM_LOOP,
EventType.CONTEXT_COMPACTED,
EventType.WORKER_LOADED,
]
# Keepalive interval in seconds
@@ -55,17 +61,14 @@ def _parse_event_types(query_param: str | None) -> list[EventType]:
async def handle_events(request: web.Request) -> web.StreamResponse:
"""GET /api/agents/{agent_id}/events — SSE event stream.
"""SSE event stream for a session.
Query params:
types: Comma-separated event type names to filter (optional).
"""
manager: SessionManager = request.app["manager"]
agent_id = request.match_info["agent_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
session, err = resolve_session(request)
if err:
return err
# Session always has an event_bus — no runtime guard needed
event_bus = session.event_bus
@@ -109,11 +112,12 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
pass
finally:
event_bus.unsubscribe(sub_id)
logger.debug(f"SSE client disconnected from agent '{agent_id}'")
logger.debug("SSE client disconnected from session '%s'", session.id)
return sse.response
def register_routes(app: web.Application) -> None:
"""Register SSE event streaming route."""
app.router.add_get("/api/agents/{agent_id}/events", handle_events)
"""Register SSE event streaming routes."""
# Session-primary route
app.router.add_get("/api/sessions/{session_id}/events", handle_events)
+30 -40
View File
@@ -5,32 +5,17 @@ import logging
from aiohttp import web
from framework.server.app import safe_path_segment, sessions_dir
from framework.server.session_manager import SessionManager
from framework.server.app import resolve_session, safe_path_segment, sessions_dir
logger = logging.getLogger(__name__)
def _get_manager(request: web.Request) -> SessionManager:
return request.app["manager"]
def _get_session_or_404(request: web.Request):
"""Lookup session by agent_id; returns (session, None) or (None, error_response)."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return None, web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
return session, None
async def handle_trigger(request: web.Request) -> web.Response:
"""POST /api/agents/{agent_id}/trigger — start an execution.
"""POST /api/sessions/{session_id}/trigger — start an execution.
Body: {"entry_point_id": "default", "input_data": {...}, "session_state": {...}?}
"""
session, err = _get_session_or_404(request)
session, err = resolve_session(request)
if err:
return err
@@ -40,7 +25,11 @@ async def handle_trigger(request: web.Request) -> web.Response:
body = await request.json()
entry_point_id = body.get("entry_point_id", "default")
input_data = body.get("input_data", {})
session_state = body.get("session_state")
session_state = body.get("session_state") or {}
# Scope the worker execution to the live session ID
if "resume_session_id" not in session_state:
session_state["resume_session_id"] = session.id
execution_id = await session.worker_runtime.trigger(
entry_point_id,
@@ -52,11 +41,11 @@ async def handle_trigger(request: web.Request) -> web.Response:
async def handle_inject(request: web.Request) -> web.Response:
"""POST /api/agents/{agent_id}/inject — inject input into a waiting node.
"""POST /api/sessions/{session_id}/inject — inject input into a waiting node.
Body: {"node_id": "...", "content": "...", "graph_id": "..."}
"""
session, err = _get_session_or_404(request)
session, err = resolve_session(request)
if err:
return err
@@ -76,7 +65,7 @@ async def handle_inject(request: web.Request) -> web.Response:
async def handle_chat(request: web.Request) -> web.Response:
"""POST /api/agents/{agent_id}/chat — convenience endpoint.
"""POST /api/sessions/{session_id}/chat — convenience endpoint.
Routing priority:
1. Worker awaiting input inject into worker node
@@ -85,7 +74,7 @@ async def handle_chat(request: web.Request) -> web.Response:
Body: {"message": "hello"}
"""
session, err = _get_session_or_404(request)
session, err = resolve_session(request)
if err:
return err
@@ -119,7 +108,7 @@ async def handle_chat(request: web.Request) -> web.Response:
if queen_executor is not None:
node = queen_executor.node_registry.get("queen")
if node is not None and hasattr(node, "inject_event"):
await node.inject_event(message)
await node.inject_event(message, is_client_input=True)
return web.json_response(
{
"status": "queen",
@@ -132,8 +121,8 @@ async def handle_chat(request: web.Request) -> web.Response:
async def handle_goal_progress(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/goal-progress — evaluate goal progress."""
session, err = _get_session_or_404(request)
"""GET /api/sessions/{session_id}/goal-progress — evaluate goal progress."""
session, err = resolve_session(request)
if err:
return err
@@ -145,11 +134,11 @@ async def handle_goal_progress(request: web.Request) -> web.Response:
async def handle_resume(request: web.Request) -> web.Response:
"""POST /api/agents/{agent_id}/resume — resume a paused execution.
"""POST /api/sessions/{session_id}/resume — resume a paused execution.
Body: {"session_id": "...", "checkpoint_id": "..." (optional)}
"""
session, err = _get_session_or_404(request)
session, err = resolve_session(request)
if err:
return err
@@ -217,11 +206,11 @@ async def handle_resume(request: web.Request) -> web.Response:
async def handle_stop(request: web.Request) -> web.Response:
"""POST /api/agents/{agent_id}/stop — cancel a running execution.
"""POST /api/sessions/{session_id}/stop — cancel a running execution.
Body: {"execution_id": "..."}
"""
session, err = _get_session_or_404(request)
session, err = resolve_session(request)
if err:
return err
@@ -252,11 +241,11 @@ async def handle_stop(request: web.Request) -> web.Response:
async def handle_replay(request: web.Request) -> web.Response:
"""POST /api/agents/{agent_id}/replay — re-run from a checkpoint.
"""POST /api/sessions/{session_id}/replay — re-run from a checkpoint.
Body: {"session_id": "...", "checkpoint_id": "..."}
"""
session, err = _get_session_or_404(request)
session, err = resolve_session(request)
if err:
return err
@@ -305,11 +294,12 @@ async def handle_replay(request: web.Request) -> web.Response:
def register_routes(app: web.Application) -> None:
"""Register execution control routes."""
app.router.add_post("/api/agents/{agent_id}/trigger", handle_trigger)
app.router.add_post("/api/agents/{agent_id}/inject", handle_inject)
app.router.add_post("/api/agents/{agent_id}/chat", handle_chat)
app.router.add_post("/api/agents/{agent_id}/pause", handle_stop) # alias
app.router.add_post("/api/agents/{agent_id}/resume", handle_resume)
app.router.add_post("/api/agents/{agent_id}/stop", handle_stop)
app.router.add_post("/api/agents/{agent_id}/replay", handle_replay)
app.router.add_get("/api/agents/{agent_id}/goal-progress", handle_goal_progress)
# Session-primary routes
app.router.add_post("/api/sessions/{session_id}/trigger", handle_trigger)
app.router.add_post("/api/sessions/{session_id}/inject", handle_inject)
app.router.add_post("/api/sessions/{session_id}/chat", handle_chat)
app.router.add_post("/api/sessions/{session_id}/pause", handle_stop)
app.router.add_post("/api/sessions/{session_id}/resume", handle_resume)
app.router.add_post("/api/sessions/{session_id}/stop", handle_stop)
app.router.add_post("/api/sessions/{session_id}/replay", handle_replay)
app.router.add_get("/api/sessions/{session_id}/goal-progress", handle_goal_progress)
+30 -42
View File
@@ -5,16 +5,11 @@ import logging
from aiohttp import web
from framework.server.app import safe_path_segment
from framework.server.session_manager import SessionManager
from framework.server.app import resolve_session, safe_path_segment
logger = logging.getLogger(__name__)
def _get_manager(request: web.Request) -> SessionManager:
return request.app["manager"]
def _get_graph_spec(session, graph_id: str):
"""Get GraphSpec for a graph_id. Returns (graph_spec, None) or (None, error_response)."""
if not session.worker_runtime:
@@ -46,15 +41,12 @@ def _node_to_dict(node) -> dict:
async def handle_list_nodes(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/graphs/{graph_id}/nodes — list nodes."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
"""List nodes in a graph."""
session, err = resolve_session(request)
if err:
return err
graph_id = request.match_info["graph_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
graph, err = _get_graph_spec(session, graph_id)
if err:
return err
@@ -108,15 +100,13 @@ async def handle_list_nodes(request: web.Request) -> web.Response:
async def handle_get_node(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/graphs/{graph_id}/nodes/{node_id} node detail."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
"""Get node detail."""
session, err = resolve_session(request)
if err:
return err
graph_id = request.match_info["graph_id"]
node_id = request.match_info["node_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
graph, err = _get_graph_spec(session, graph_id)
if err:
@@ -138,15 +128,13 @@ async def handle_get_node(request: web.Request) -> web.Response:
async def handle_node_criteria(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/graphs/{graph_id}/nodes/{node_id}/criteria"""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
"""Get node success criteria and last execution info."""
session, err = resolve_session(request)
if err:
return err
graph_id = request.match_info["graph_id"]
node_id = request.match_info["node_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
graph, err = _get_graph_spec(session, graph_id)
if err:
@@ -183,15 +171,13 @@ async def handle_node_criteria(request: web.Request) -> web.Response:
async def handle_node_tools(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/graphs/{graph_id}/nodes/{node_id}/tools"""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
"""Get tools available to a node."""
session, err = resolve_session(request)
if err:
return err
graph_id = request.match_info["graph_id"]
node_id = request.match_info["node_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
graph, err = _get_graph_spec(session, graph_id)
if err:
@@ -206,9 +192,8 @@ async def handle_node_tools(request: web.Request) -> web.Response:
all_tools = registry.get_tools() if registry else {}
for name in node_spec.tools:
registered = all_tools.get(name)
if registered:
tool = registered.tool
tool = all_tools.get(name)
if tool:
tools_out.append(
{
"name": tool.name,
@@ -224,13 +209,16 @@ async def handle_node_tools(request: web.Request) -> web.Response:
def register_routes(app: web.Application) -> None:
"""Register graph/node inspection routes."""
app.router.add_get("/api/agents/{agent_id}/graphs/{graph_id}/nodes", handle_list_nodes)
app.router.add_get("/api/agents/{agent_id}/graphs/{graph_id}/nodes/{node_id}", handle_get_node)
# Session-primary routes
app.router.add_get("/api/sessions/{session_id}/graphs/{graph_id}/nodes", handle_list_nodes)
app.router.add_get(
"/api/agents/{agent_id}/graphs/{graph_id}/nodes/{node_id}/criteria",
"/api/sessions/{session_id}/graphs/{graph_id}/nodes/{node_id}", handle_get_node
)
app.router.add_get(
"/api/sessions/{session_id}/graphs/{graph_id}/nodes/{node_id}/criteria",
handle_node_criteria,
)
app.router.add_get(
"/api/agents/{agent_id}/graphs/{graph_id}/nodes/{node_id}/tools",
"/api/sessions/{session_id}/graphs/{graph_id}/nodes/{node_id}/tools",
handle_node_tools,
)
+14 -31
View File
@@ -2,41 +2,25 @@
import json
import logging
from pathlib import Path
from aiohttp import web
from framework.server.session_manager import SessionManager
from framework.server.app import resolve_session
logger = logging.getLogger(__name__)
def _get_manager(request: web.Request) -> SessionManager:
return request.app["manager"]
def _storage_path(session) -> Path:
"""Resolve the storage root for a session's worker."""
if session.worker_path is None:
raise ValueError("No worker loaded — no worker storage path")
agent_name = session.worker_path.name
return Path.home() / ".hive" / "agents" / agent_name
async def handle_logs(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/logs — agent-level logs.
"""Session-level logs.
Query params:
session_id: Scope to a specific session (optional).
session_id: Scope to a specific worker session (optional).
level: "summary" | "details" | "tools" (default: "summary").
limit: Max results when listing summaries (default: 20).
"""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
session, err = resolve_session(request)
if err:
return err
if not session.worker_runtime:
return web.json_response({"error": "No worker loaded in this session"}, status=503)
@@ -86,14 +70,12 @@ async def handle_logs(request: web.Request) -> web.Response:
async def handle_node_logs(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/graphs/{graph_id}/nodes/{node_id}/logs"""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
node_id = request.match_info["node_id"]
session = manager.get_session_for_agent(agent_id)
"""Node-scoped logs."""
session, err = resolve_session(request)
if err:
return err
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
node_id = request.match_info["node_id"]
if not session.worker_runtime:
return web.json_response({"error": "No worker loaded in this session"}, status=503)
@@ -124,8 +106,9 @@ async def handle_node_logs(request: web.Request) -> web.Response:
def register_routes(app: web.Application) -> None:
"""Register log routes."""
app.router.add_get("/api/agents/{agent_id}/logs", handle_logs)
# Session-primary routes
app.router.add_get("/api/sessions/{session_id}/logs", handle_logs)
app.router.add_get(
"/api/agents/{agent_id}/graphs/{graph_id}/nodes/{node_id}/logs",
"/api/sessions/{session_id}/graphs/{graph_id}/nodes/{node_id}/logs",
handle_node_logs,
)
+415 -135
View File
@@ -1,29 +1,36 @@
"""Session lifecycle and browsing routes.
"""Session lifecycle, info, and worker-session browsing routes.
New session-primary routes:
- POST /api/sessions create a session (queen-only)
- GET /api/sessions list all sessions
- POST /api/sessions/{session_id}/worker load a worker into session
- DELETE /api/sessions/{session_id}/worker unload worker from session
- DELETE /api/sessions/{session_id} stop session entirely
Session-primary routes:
- POST /api/sessions create session (with or without worker)
- GET /api/sessions list all active sessions
- GET /api/sessions/{session_id} session detail
- DELETE /api/sessions/{session_id} stop session entirely
- POST /api/sessions/{session_id}/worker load a worker into session
- DELETE /api/sessions/{session_id}/worker unload worker from session
- GET /api/sessions/{session_id}/stats runtime statistics
- GET /api/sessions/{session_id}/entry-points list entry points
- GET /api/sessions/{session_id}/graphs list graph IDs
- GET /api/sessions/{session_id}/queen-messages queen conversation history
Worker session browsing (persisted execution runs on disk):
- GET /api/sessions/{session_id}/worker-sessions list
- GET /api/sessions/{session_id}/worker-sessions/{ws_id} detail
- DELETE /api/sessions/{session_id}/worker-sessions/{ws_id} delete
- GET /api/sessions/{session_id}/worker-sessions/{ws_id}/checkpoints list CPs
- POST /api/sessions/{session_id}/worker-sessions/{ws_id}/checkpoints/{cp}/restore
- GET /api/sessions/{session_id}/worker-sessions/{ws_id}/messages messages
Legacy worker session browsing routes (backward compat):
- GET /api/agents/{agent_id}/sessions list worker sessions on disk
- GET /api/agents/{agent_id}/sessions/{session_id} session detail
- DELETE /api/agents/{agent_id}/sessions/{session_id} delete session
- GET /api/agents/{agent_id}/sessions/{session_id}/checkpoints
- POST /api/agents/{agent_id}/sessions/{session_id}/checkpoints/{cp_id}/restore
- GET /api/agents/{agent_id}/sessions/{session_id}/messages
"""
import json
import logging
import shutil
import time
from pathlib import Path
from aiohttp import web
from framework.server.app import safe_path_segment, sessions_dir
from framework.server.app import resolve_session, safe_path_segment, sessions_dir
from framework.server.session_manager import SessionManager
logger = logging.getLogger(__name__)
@@ -33,56 +40,182 @@ def _get_manager(request: web.Request) -> SessionManager:
return request.app["manager"]
def _session_to_live_dict(session) -> dict:
"""Serialize a live Session to the session-primary JSON shape."""
info = session.worker_info
return {
"session_id": session.id,
"worker_id": session.worker_id,
"worker_name": info.name if info else session.worker_id,
"has_worker": session.worker_runtime is not None,
"agent_path": str(session.worker_path) if session.worker_path else "",
"description": info.description if info else "",
"goal": info.goal_name if info else "",
"node_count": info.node_count if info else 0,
"loaded_at": session.loaded_at,
"uptime_seconds": round(time.time() - session.loaded_at, 1),
"intro_message": getattr(session.runner, "intro_message", "") or "",
}
def _credential_error_response(exc: Exception, agent_path: str | None) -> web.Response | None:
"""If *exc* is a CredentialError, return a 424 with structured credential info.
Returns None if *exc* is not a credential error (caller should handle it).
Uses the CredentialValidationResult attached by validate_agent_credentials.
"""
from framework.credentials.models import CredentialError
if not isinstance(exc, CredentialError):
return None
from framework.server.routes_credentials import _status_to_dict
# Prefer the structured validation result attached to the exception
validation_result = getattr(exc, "validation_result", None)
if validation_result is not None:
required = [_status_to_dict(c) for c in validation_result.failed]
else:
# Fallback for exceptions without a validation result
required = []
return web.json_response(
{
"error": "credentials_required",
"message": str(exc),
"agent_path": agent_path or "",
"required": required,
},
status=424,
)
# ------------------------------------------------------------------
# New session-primary routes
# Session lifecycle
# ------------------------------------------------------------------
async def handle_create_session(request: web.Request) -> web.Response:
"""POST /api/sessions — create a queen-only session.
"""POST /api/sessions — create a session.
Body: {"session_id": "..." (optional), "model": "..." (optional)}
Body: {
"agent_path": "..." (optional if provided, creates session with worker),
"agent_id": "..." (optional worker ID override),
"session_id": "..." (optional custom session ID),
"model": "..." (optional),
"initial_prompt": "..." (optional first user message for the queen),
}
When agent_path is provided, creates a session with a worker in one step
(equivalent to the old POST /api/agents). Otherwise creates a queen-only
session that can later have a worker loaded via POST /sessions/{id}/worker.
"""
manager = _get_manager(request)
body = await request.json() if request.can_read_body else {}
agent_path = body.get("agent_path")
agent_id = body.get("agent_id")
session_id = body.get("session_id")
model = body.get("model")
initial_prompt = body.get("initial_prompt")
try:
session = await manager.create_session(session_id=session_id, model=model)
if agent_path:
# One-step: create session + load worker
session = await manager.create_session_with_worker(
agent_path,
agent_id=agent_id,
model=model,
initial_prompt=initial_prompt,
)
else:
# Queen-only session
session = await manager.create_session(
session_id=session_id,
model=model,
initial_prompt=initial_prompt,
)
except ValueError as e:
return web.json_response({"error": str(e)}, status=409)
msg = str(e)
if "currently loading" in msg:
resolved_id = agent_id or (Path(agent_path).name if agent_path else "")
return web.json_response(
{"error": msg, "worker_id": resolved_id, "loading": True},
status=409,
)
return web.json_response({"error": msg}, status=409)
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except Exception as e:
logger.exception(f"Error creating session: {e}")
resp = _credential_error_response(e, agent_path)
if resp is not None:
return resp
logger.exception("Error creating session: %s", e)
return web.json_response({"error": str(e)}, status=500)
return web.json_response(
{
"session_id": session.id,
"has_worker": session.worker_runtime is not None,
"loaded_at": session.loaded_at,
},
status=201,
)
return web.json_response(_session_to_live_dict(session), status=201)
async def handle_list_live_sessions(request: web.Request) -> web.Response:
"""GET /api/sessions — list all active sessions."""
manager = _get_manager(request)
sessions = []
for s in manager.list_sessions():
sessions.append(
{
"session_id": s.id,
"worker_id": s.worker_id,
"has_worker": s.worker_runtime is not None,
"loaded_at": s.loaded_at,
"uptime_seconds": round(time.time() - s.loaded_at, 1),
}
)
sessions = [_session_to_live_dict(s) for s in manager.list_sessions()]
return web.json_response({"sessions": sessions})
async def handle_get_live_session(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id} — get session detail."""
manager = _get_manager(request)
session_id = request.match_info["session_id"]
session = manager.get_session(session_id)
if session is None:
if manager.is_loading(session_id):
return web.json_response(
{"session_id": session_id, "loading": True},
status=202,
)
return web.json_response(
{"error": f"Session '{session_id}' not found"},
status=404,
)
data = _session_to_live_dict(session)
if session.worker_runtime:
data["entry_points"] = [
{
"id": ep.id,
"name": ep.name,
"entry_node": ep.entry_node,
"trigger_type": ep.trigger_type,
}
for ep in session.worker_runtime.get_entry_points()
]
data["graphs"] = session.worker_runtime.list_graphs()
return web.json_response(data)
async def handle_stop_session(request: web.Request) -> web.Response:
"""DELETE /api/sessions/{session_id} — stop a session entirely."""
manager = _get_manager(request)
session_id = request.match_info["session_id"]
stopped = await manager.stop_session(session_id)
if not stopped:
return web.json_response(
{"error": f"Session '{session_id}' not found"},
status=404,
)
return web.json_response({"session_id": session_id, "stopped": True})
# ------------------------------------------------------------------
# Worker lifecycle
# ------------------------------------------------------------------
async def handle_load_worker(request: web.Request) -> web.Response:
"""POST /api/sessions/{session_id}/worker — load a worker into a session.
@@ -101,25 +234,23 @@ async def handle_load_worker(request: web.Request) -> web.Response:
try:
session = await manager.load_worker(
session_id, agent_path, worker_id=worker_id, model=model,
session_id,
agent_path,
worker_id=worker_id,
model=model,
)
except ValueError as e:
return web.json_response({"error": str(e)}, status=409)
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except Exception as e:
logger.exception(f"Error loading worker: {e}")
resp = _credential_error_response(e, agent_path)
if resp is not None:
return resp
logger.exception("Error loading worker: %s", e)
return web.json_response({"error": str(e)}, status=500)
info = session.worker_info
return web.json_response(
{
"session_id": session.id,
"worker_id": session.worker_id,
"worker_name": info.name if info else session.worker_id,
"worker_description": info.description if info else "",
}
)
return web.json_response(_session_to_live_dict(session))
async def handle_unload_worker(request: web.Request) -> web.Response:
@@ -131,37 +262,93 @@ async def handle_unload_worker(request: web.Request) -> web.Response:
if not removed:
session = manager.get_session(session_id)
if session is None:
return web.json_response({"error": f"Session '{session_id}' not found"}, status=404)
return web.json_response({"error": "No worker loaded in this session"}, status=409)
return web.json_response(
{"error": f"Session '{session_id}' not found"},
status=404,
)
return web.json_response(
{"error": "No worker loaded in this session"},
status=409,
)
return web.json_response({"session_id": session_id, "worker_unloaded": True})
async def handle_stop_session(request: web.Request) -> web.Response:
"""DELETE /api/sessions/{session_id} — stop a session entirely."""
# ------------------------------------------------------------------
# Session info (worker details)
# ------------------------------------------------------------------
async def handle_session_stats(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/stats — runtime statistics."""
manager = _get_manager(request)
session_id = request.match_info["session_id"]
stopped = await manager.stop_session(session_id)
if not stopped:
return web.json_response({"error": f"Session '{session_id}' not found"}, status=404)
return web.json_response({"session_id": session_id, "stopped": True})
# ------------------------------------------------------------------
# Legacy worker session browsing routes (unchanged URLs)
# ------------------------------------------------------------------
async def handle_list_sessions(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/sessions — list worker sessions on disk."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
session = manager.get_session_for_agent(agent_id)
session = manager.get_session(session_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
return web.json_response(
{"error": f"Session '{session_id}' not found"},
status=404,
)
stats = session.worker_runtime.get_stats() if session.worker_runtime else {}
return web.json_response(stats)
async def handle_session_entry_points(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/entry-points — list entry points."""
manager = _get_manager(request)
session_id = request.match_info["session_id"]
session = manager.get_session(session_id)
if session is None:
return web.json_response(
{"error": f"Session '{session_id}' not found"},
status=404,
)
eps = session.worker_runtime.get_entry_points() if session.worker_runtime else []
return web.json_response(
{
"entry_points": [
{
"id": ep.id,
"name": ep.name,
"entry_node": ep.entry_node,
"trigger_type": ep.trigger_type,
}
for ep in eps
]
}
)
async def handle_session_graphs(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/graphs — list loaded graphs."""
manager = _get_manager(request)
session_id = request.match_info["session_id"]
session = manager.get_session(session_id)
if session is None:
return web.json_response(
{"error": f"Session '{session_id}' not found"},
status=404,
)
graphs = session.worker_runtime.list_graphs() if session.worker_runtime else []
return web.json_response({"graphs": graphs})
# ------------------------------------------------------------------
# Worker session browsing (persisted execution runs on disk)
# ------------------------------------------------------------------
async def handle_list_worker_sessions(request: web.Request) -> web.Response:
"""List worker sessions on disk."""
session, err = resolve_session(request)
if err:
return err
if not session.worker_path:
return web.json_response({"sessions": []})
@@ -201,20 +388,20 @@ async def handle_list_sessions(request: web.Request) -> web.Response:
return web.json_response({"sessions": sessions})
async def handle_get_session(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/sessions/{session_id} session detail."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
worker_session_id = safe_path_segment(request.match_info["session_id"])
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
async def handle_get_worker_session(request: web.Request) -> web.Response:
"""Get worker session detail from disk."""
session, err = resolve_session(request)
if err:
return err
if not session.worker_path:
return web.json_response({"error": "No worker loaded"}, status=503)
state_path = sessions_dir(session) / worker_session_id / "state.json"
# Support both URL param names: ws_id (new) or session_id (legacy)
ws_id = request.match_info.get("ws_id") or request.match_info.get("session_id", "")
ws_id = safe_path_segment(ws_id)
state_path = sessions_dir(session) / ws_id / "state.json"
if not state_path.exists():
return web.json_response({"error": "Session not found"}, status=404)
@@ -227,19 +414,18 @@ async def handle_get_session(request: web.Request) -> web.Response:
async def handle_list_checkpoints(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/sessions/{session_id}/checkpoints"""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
worker_session_id = safe_path_segment(request.match_info["session_id"])
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
"""List checkpoints for a worker session."""
session, err = resolve_session(request)
if err:
return err
if not session.worker_path:
return web.json_response({"error": "No worker loaded"}, status=503)
cp_dir = sessions_dir(session) / worker_session_id / "checkpoints"
ws_id = request.match_info.get("ws_id") or request.match_info.get("session_id", "")
ws_id = safe_path_segment(ws_id)
cp_dir = sessions_dir(session) / ws_id / "checkpoints"
if not cp_dir.exists():
return web.json_response({"checkpoints": []})
@@ -264,42 +450,40 @@ async def handle_list_checkpoints(request: web.Request) -> web.Response:
return web.json_response({"checkpoints": checkpoints})
async def handle_delete_session(request: web.Request) -> web.Response:
"""DELETE /api/agents/{agent_id}/sessions/{session_id} — delete a worker session."""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
worker_session_id = safe_path_segment(request.match_info["session_id"])
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
async def handle_delete_worker_session(request: web.Request) -> web.Response:
"""Delete a worker session from disk."""
session, err = resolve_session(request)
if err:
return err
if not session.worker_path:
return web.json_response({"error": "No worker loaded"}, status=503)
session_path = sessions_dir(session) / worker_session_id
ws_id = request.match_info.get("ws_id") or request.match_info.get("session_id", "")
ws_id = safe_path_segment(ws_id)
session_path = sessions_dir(session) / ws_id
if not session_path.exists():
return web.json_response({"error": "Session not found"}, status=404)
shutil.rmtree(session_path)
return web.json_response({"deleted": worker_session_id})
return web.json_response({"deleted": ws_id})
async def handle_restore_checkpoint(request: web.Request) -> web.Response:
"""POST /api/agents/{agent_id}/sessions/{session_id}/checkpoints/{checkpoint_id}/restore"""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
worker_session_id = safe_path_segment(request.match_info["session_id"])
checkpoint_id = safe_path_segment(request.match_info["checkpoint_id"])
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
"""Restore from a checkpoint."""
session, err = resolve_session(request)
if err:
return err
if not session.worker_runtime:
return web.json_response({"error": "No worker loaded in this session"}, status=503)
cp_path = sessions_dir(session) / worker_session_id / "checkpoints" / f"{checkpoint_id}.json"
ws_id = request.match_info.get("ws_id") or request.match_info.get("session_id", "")
ws_id = safe_path_segment(ws_id)
checkpoint_id = safe_path_segment(request.match_info["checkpoint_id"])
cp_path = sessions_dir(session) / ws_id / "checkpoints" / f"{checkpoint_id}.json"
if not cp_path.exists():
return web.json_response({"error": "Checkpoint not found"}, status=404)
@@ -308,7 +492,7 @@ async def handle_restore_checkpoint(request: web.Request) -> web.Response:
return web.json_response({"error": "No entry points available"}, status=400)
restore_session_state = {
"resume_session_id": worker_session_id,
"resume_session_id": ws_id,
"resume_from_checkpoint": checkpoint_id,
}
@@ -321,26 +505,25 @@ async def handle_restore_checkpoint(request: web.Request) -> web.Response:
return web.json_response(
{
"execution_id": execution_id,
"restored_from": worker_session_id,
"restored_from": ws_id,
"checkpoint_id": checkpoint_id,
}
)
async def handle_messages(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_id}/sessions/{session_id}/messages"""
manager = _get_manager(request)
agent_id = request.match_info["agent_id"]
worker_session_id = safe_path_segment(request.match_info["session_id"])
session = manager.get_session_for_agent(agent_id)
if session is None:
return web.json_response({"error": f"Agent '{agent_id}' not found"}, status=404)
"""Get messages for a worker session."""
session, err = resolve_session(request)
if err:
return err
if not session.worker_path:
return web.json_response({"error": "No worker loaded"}, status=503)
convs_dir = sessions_dir(session) / worker_session_id / "conversations"
ws_id = request.match_info.get("ws_id") or request.match_info.get("session_id", "")
ws_id = safe_path_segment(ws_id)
convs_dir = sessions_dir(session) / ws_id / "conversations"
if not convs_dir.exists():
return web.json_response({"messages": []})
@@ -393,28 +576,125 @@ async def handle_messages(request: web.Request) -> web.Response:
return web.json_response({"messages": all_messages})
async def handle_queen_messages(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/queen-messages — get queen conversation."""
session, err = resolve_session(request)
if err:
return err
queen_dir = Path.home() / ".hive" / "queen" / "session" / session.id
convs_dir = queen_dir / "conversations"
if not convs_dir.exists():
return web.json_response({"messages": []})
all_messages: list[dict] = []
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir():
continue
parts_dir = node_dir / "parts"
if not parts_dir.exists():
continue
for part_file in sorted(parts_dir.iterdir()):
if part_file.suffix != ".json":
continue
try:
part = json.loads(part_file.read_text())
part["_node_id"] = node_dir.name
all_messages.append(part)
except (json.JSONDecodeError, OSError):
continue
all_messages.sort(key=lambda m: m.get("seq", 0))
# Filter to client-facing messages only
all_messages = [
m
for m in all_messages
if not m.get("is_transition_marker")
and m["role"] != "tool"
and not (m["role"] == "assistant" and m.get("tool_calls"))
]
return web.json_response({"messages": all_messages})
# ------------------------------------------------------------------
# Agent discovery (not session-specific)
# ------------------------------------------------------------------
async def handle_discover(request: web.Request) -> web.Response:
"""GET /api/discover — discover agents from filesystem."""
from framework.tui.screens.agent_picker import discover_agents
manager = _get_manager(request)
loaded_paths = {str(s.worker_path) for s in manager.list_sessions() if s.worker_path}
groups = discover_agents()
result = {}
for category, entries in groups.items():
result[category] = [
{
"path": str(entry.path),
"name": entry.name,
"description": entry.description,
"category": entry.category,
"session_count": entry.session_count,
"node_count": entry.node_count,
"tool_count": entry.tool_count,
"tags": entry.tags,
"last_active": entry.last_active,
"is_loaded": str(entry.path) in loaded_paths,
"version": entry.version,
}
for entry in entries
]
return web.json_response(result)
# ------------------------------------------------------------------
# Route registration
# ------------------------------------------------------------------
def register_routes(app: web.Application) -> None:
"""Register session routes."""
# New session-primary routes
# Discovery
app.router.add_get("/api/discover", handle_discover)
# Session lifecycle
app.router.add_post("/api/sessions", handle_create_session)
app.router.add_get("/api/sessions", handle_list_live_sessions)
app.router.add_post("/api/sessions/{session_id}/worker", handle_load_worker)
app.router.add_delete("/api/sessions/{session_id}/worker", handle_unload_worker)
app.router.add_get("/api/sessions/{session_id}", handle_get_live_session)
app.router.add_delete("/api/sessions/{session_id}", handle_stop_session)
# Legacy worker session browsing routes
app.router.add_get("/api/agents/{agent_id}/sessions", handle_list_sessions)
app.router.add_get("/api/agents/{agent_id}/sessions/{session_id}", handle_get_session)
app.router.add_delete("/api/agents/{agent_id}/sessions/{session_id}", handle_delete_session)
# Worker lifecycle
app.router.add_post("/api/sessions/{session_id}/worker", handle_load_worker)
app.router.add_delete("/api/sessions/{session_id}/worker", handle_unload_worker)
# Session info
app.router.add_get("/api/sessions/{session_id}/stats", handle_session_stats)
app.router.add_get("/api/sessions/{session_id}/entry-points", handle_session_entry_points)
app.router.add_get("/api/sessions/{session_id}/graphs", handle_session_graphs)
app.router.add_get("/api/sessions/{session_id}/queen-messages", handle_queen_messages)
# Worker session browsing (session-primary)
app.router.add_get("/api/sessions/{session_id}/worker-sessions", handle_list_worker_sessions)
app.router.add_get(
"/api/agents/{agent_id}/sessions/{session_id}/checkpoints",
"/api/sessions/{session_id}/worker-sessions/{ws_id}", handle_get_worker_session
)
app.router.add_delete(
"/api/sessions/{session_id}/worker-sessions/{ws_id}", handle_delete_worker_session
)
app.router.add_get(
"/api/sessions/{session_id}/worker-sessions/{ws_id}/checkpoints",
handle_list_checkpoints,
)
app.router.add_post(
"/api/agents/{agent_id}/sessions/{session_id}/checkpoints/{checkpoint_id}/restore",
"/api/sessions/{session_id}/worker-sessions/{ws_id}/checkpoints/{checkpoint_id}/restore",
handle_restore_checkpoint,
)
app.router.add_get(
"/api/agents/{agent_id}/sessions/{session_id}/messages",
"/api/sessions/{session_id}/worker-sessions/{ws_id}/messages",
handle_messages,
)
+339
View File
@@ -0,0 +1,339 @@
"""Agent version management routes.
Agent-bound endpoints for git-based versioning. These are NOT session-bound
the version history lives with the agent in exports/{agent_name}/.
Endpoints:
- GET /api/agents/{agent_name}/versions list all versions
- POST /api/agents/{agent_name}/versions create new version
- GET /api/agents/{agent_name}/versions/{version} version detail
- DELETE /api/agents/{agent_name}/versions/{version} delete version tag
- GET /api/agents/{agent_name}/history commit log
- GET /api/agents/{agent_name}/files list files at ref
- GET /api/agents/{agent_name}/files/{path:.+} file content at ref
- GET /api/agents/{agent_name}/diff diff between refs
"""
import logging
from pathlib import Path
from aiohttp import web
from framework.server.app import safe_path_segment
from framework.utils.git import (
commit_all,
create_tag,
delete_tag,
diff_between,
has_changes,
is_git_repo,
latest_version,
list_files_at_ref,
list_tags,
log,
next_version,
parse_semver,
show_file,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_agent_dir(request: web.Request) -> tuple[Path, str]:
"""Resolve {agent_name} to an exports/ directory.
Returns (agent_dir, agent_name).
Raises HTTPNotFound if the directory doesn't exist.
Raises HTTPBadRequest if the name is unsafe.
"""
agent_name = safe_path_segment(request.match_info["agent_name"])
agent_dir = Path("exports") / agent_name
if not agent_dir.is_dir():
raise web.HTTPNotFound(
text=f'{{"error": "Agent \\"{agent_name}\\" not found in exports/"}}',
content_type="application/json",
)
return agent_dir, agent_name
def _require_git(agent_dir: Path, agent_name: str) -> None:
"""Raise HTTPConflict if the agent dir is not a git repo."""
if not is_git_repo(agent_dir):
raise web.HTTPConflict(
text=f'{{"error": "Agent \\"{agent_name}\\" has no git repository. Export the agent first."}}',
content_type="application/json",
)
# ---------------------------------------------------------------------------
# Version listing and creation
# ---------------------------------------------------------------------------
async def handle_list_versions(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_name}/versions — list all semver versions."""
agent_dir, agent_name = _resolve_agent_dir(request)
if not is_git_repo(agent_dir):
return web.json_response({
"agent_name": agent_name,
"has_git": False,
"versions": [],
"latest": None,
})
tags = list_tags(agent_dir)
return web.json_response({
"agent_name": agent_name,
"has_git": True,
"versions": tags,
"latest": tags[0]["tag"] if tags else None,
})
async def handle_create_version(request: web.Request) -> web.Response:
"""POST /api/agents/{agent_name}/versions — create a new version tag.
Body: {"version": "v1.0.0", "message": "..."} or {"bump": "patch", "message": "..."}
"""
agent_dir, agent_name = _resolve_agent_dir(request)
_require_git(agent_dir, agent_name)
body = await request.json()
version = body.get("version")
bump = body.get("bump")
message = body.get("message", "")
if not version and not bump:
return web.json_response(
{"error": "Provide 'version' (e.g. 'v1.0.0') or 'bump' ('patch'|'minor'|'major')"},
status=400,
)
if bump:
if bump not in ("patch", "minor", "major"):
return web.json_response(
{"error": f"Invalid bump type '{bump}'. Use 'patch', 'minor', or 'major'"},
status=400,
)
version = next_version(agent_dir, bump)
# Auto-commit uncommitted changes before tagging
if has_changes(agent_dir):
commit_all(agent_dir, "pre-release commit")
try:
create_tag(agent_dir, version, message)
except ValueError as e:
return web.json_response({"error": str(e)}, status=409)
except RuntimeError as e:
return web.json_response({"error": str(e)}, status=500)
# Find the tag info we just created
tags = list_tags(agent_dir)
tag_info = next((t for t in tags if t["tag"] == version), {"tag": version})
return web.json_response(tag_info, status=201)
async def handle_get_version(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_name}/versions/{version} — version detail."""
agent_dir, agent_name = _resolve_agent_dir(request)
_require_git(agent_dir, agent_name)
version = request.match_info["version"]
# Find the tag
tags = list_tags(agent_dir)
tag_info = next((t for t in tags if t["tag"] == version), None)
if tag_info is None:
return web.json_response(
{"error": f"Version '{version}' not found"},
status=404,
)
# Get files at this version
files = list_files_at_ref(agent_dir, version)
# Get diff from previous version
tag_idx = next((i for i, t in enumerate(tags) if t["tag"] == version), -1)
prev_diff = ""
if tag_idx >= 0 and tag_idx + 1 < len(tags):
prev_tag = tags[tag_idx + 1]["tag"]
prev_diff = diff_between(agent_dir, prev_tag, version)
return web.json_response({
**tag_info,
"files": files,
"diff_from_previous": prev_diff,
})
async def handle_delete_version(request: web.Request) -> web.Response:
"""DELETE /api/agents/{agent_name}/versions/{version} — delete a version tag."""
agent_dir, agent_name = _resolve_agent_dir(request)
_require_git(agent_dir, agent_name)
version = request.match_info["version"]
try:
delete_tag(agent_dir, version)
except ValueError as e:
return web.json_response({"error": str(e)}, status=404)
return web.json_response({"deleted": version})
# ---------------------------------------------------------------------------
# Commit history
# ---------------------------------------------------------------------------
async def handle_history(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_name}/history — commit log.
Query params: ?limit=50&since=v1.0.0
"""
agent_dir, agent_name = _resolve_agent_dir(request)
_require_git(agent_dir, agent_name)
limit = int(request.query.get("limit", "50"))
since = request.query.get("since", "")
commits = log(agent_dir, limit=limit, since_tag=since)
return web.json_response({
"agent_name": agent_name,
"commits": commits,
"total": len(commits),
})
# ---------------------------------------------------------------------------
# File inspection at specific refs
# ---------------------------------------------------------------------------
async def handle_list_files(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_name}/files — list files at a ref.
Query params: ?ref=v1.0.0 (default: HEAD)
"""
agent_dir, agent_name = _resolve_agent_dir(request)
_require_git(agent_dir, agent_name)
ref = request.query.get("ref", "HEAD")
files = list_files_at_ref(agent_dir, ref)
if not files and ref != "HEAD":
return web.json_response(
{"error": f"Ref '{ref}' not found or has no files"},
status=404,
)
return web.json_response({
"agent_name": agent_name,
"ref": ref,
"files": files,
})
async def handle_get_file(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_name}/files/{path:.+} — file content at a ref.
Query params: ?ref=v1.0.0 (default: HEAD)
"""
agent_dir, agent_name = _resolve_agent_dir(request)
_require_git(agent_dir, agent_name)
file_path = request.match_info["path"]
ref = request.query.get("ref", "HEAD")
content = show_file(agent_dir, ref, file_path)
if content is None:
return web.json_response(
{"error": f"File '{file_path}' not found at ref '{ref}'"},
status=404,
)
return web.json_response({
"agent_name": agent_name,
"ref": ref,
"path": file_path,
"content": content,
})
# ---------------------------------------------------------------------------
# Diff
# ---------------------------------------------------------------------------
async def handle_diff(request: web.Request) -> web.Response:
"""GET /api/agents/{agent_name}/diff — diff between two refs.
Query params: ?from=v1.0.0&to=v1.1.0
"""
agent_dir, agent_name = _resolve_agent_dir(request)
_require_git(agent_dir, agent_name)
ref_from = request.query.get("from")
ref_to = request.query.get("to", "HEAD")
if not ref_from:
return web.json_response(
{"error": "'from' query parameter is required"},
status=400,
)
diff = diff_between(agent_dir, ref_from, ref_to)
return web.json_response({
"agent_name": agent_name,
"from": ref_from,
"to": ref_to,
"diff": diff,
})
# ---------------------------------------------------------------------------
# Route registration
# ---------------------------------------------------------------------------
def register_routes(app: web.Application) -> None:
"""Register version management routes."""
# Versions (tags)
app.router.add_get(
"/api/agents/{agent_name}/versions", handle_list_versions
)
app.router.add_post(
"/api/agents/{agent_name}/versions", handle_create_version
)
app.router.add_get(
"/api/agents/{agent_name}/versions/{version}", handle_get_version
)
app.router.add_delete(
"/api/agents/{agent_name}/versions/{version}", handle_delete_version
)
# History
app.router.add_get(
"/api/agents/{agent_name}/history", handle_history
)
# Files at ref
app.router.add_get(
"/api/agents/{agent_name}/files", handle_list_files
)
app.router.add_get(
"/api/agents/{agent_name}/files/{path:.+}", handle_get_file
)
# Diff
app.router.add_get(
"/api/agents/{agent_name}/diff", handle_diff
)
+68 -43
View File
@@ -15,7 +15,7 @@ import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -43,8 +43,6 @@ class Session:
# Judge (active when worker is loaded)
judge_task: asyncio.Task | None = None
escalation_sub: str | None = None
# Internal
_session_id: str = "" # storage-scoped session ID
class SessionManager:
@@ -77,16 +75,13 @@ class SessionManager:
from framework.llm.litellm import LiteLLMProvider
from framework.runtime.event_bus import EventBus
resolved_id = session_id or f"session_{uuid.uuid4().hex[:8]}"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
resolved_id = session_id or f"session_{ts}_{uuid.uuid4().hex[:8]}"
async with self._lock:
if resolved_id in self._sessions:
raise ValueError(f"Session '{resolved_id}' already exists")
# Session-scoped storage ID
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
sid = f"session_{ts}_{uuid.uuid4().hex[:8]}"
# Load LLM config from ~/.hive/configuration.json
rc = RuntimeConfig(model=model or self._model or RuntimeConfig().model)
@@ -104,7 +99,6 @@ class SessionManager:
event_bus=event_bus,
llm=llm,
loaded_at=time.time(),
_session_id=sid,
)
async with self._lock:
@@ -116,6 +110,7 @@ class SessionManager:
self,
session_id: str | None = None,
model: str | None = None,
initial_prompt: str | None = None,
) -> Session:
"""Create a new session with a queen but no worker.
@@ -125,7 +120,7 @@ class SessionManager:
session = await self._create_session_core(session_id=session_id, model=model)
# Start queen immediately (queen-only, no worker tools yet)
await self._start_queen(session, worker_identity=None)
await self._start_queen(session, worker_identity=None, initial_prompt=initial_prompt)
logger.info("Session '%s' created (queen-only)", session.id)
return session
@@ -135,6 +130,7 @@ class SessionManager:
agent_path: str | Path,
agent_id: str | None = None,
model: str | None = None,
initial_prompt: str | None = None,
) -> Session:
"""Create a session and load a worker in one step.
@@ -150,28 +146,28 @@ class SessionManager:
agent_path = Path(agent_path)
resolved_worker_id = agent_id or agent_path.name
# Check if this agent is already loaded in any session
existing = self.get_session_by_worker_id(resolved_worker_id)
if existing:
raise ValueError(
f"Agent '{resolved_worker_id}' already loaded"
)
# Auto-generate session ID (not the agent name)
session = await self._create_session_core(model=model)
try:
# Load worker FIRST (before queen) so queen gets full tools
await self._load_worker_core(
session, agent_path, worker_id=resolved_worker_id, model=model,
session,
agent_path,
worker_id=resolved_worker_id,
model=model,
)
# Start queen with worker profile + lifecycle + monitoring tools
worker_identity = (
build_worker_profile(session.worker_runtime)
build_worker_profile(
session.worker_runtime,
agent_path=session.worker_path,
storage_path=session.runner._storage_path if session.runner else None,
)
if session.worker_runtime
else None
)
await self._start_queen(session, worker_identity=worker_identity)
await self._start_queen(session, worker_identity=worker_identity, initial_prompt=initial_prompt)
# Start health judge
if agent_path.name != "hive_coder" and session.worker_runtime:
@@ -205,14 +201,12 @@ class SessionManager:
resolved_worker_id = worker_id or agent_path.name
if session.worker_runtime is not None:
raise ValueError(
f"Session '{session.id}' already has worker '{session.worker_id}'"
)
raise ValueError(f"Session '{session.id}' already has worker '{session.worker_id}'")
async with self._lock:
if resolved_worker_id in self._loading:
raise ValueError(f"Worker '{resolved_worker_id}' is currently loading")
self._loading.add(resolved_worker_id)
if session.id in self._loading:
raise ValueError(f"Session '{session.id}' is currently loading a worker")
self._loading.add(session.id)
try:
# Blocking I/O — load in executor
@@ -250,7 +244,7 @@ class SessionManager:
session.worker_info = info
async with self._lock:
self._loading.discard(resolved_worker_id)
self._loading.discard(session.id)
logger.info(
"Worker '%s' loaded into session '%s'",
@@ -260,7 +254,7 @@ class SessionManager:
except Exception:
async with self._lock:
self._loading.discard(resolved_worker_id)
self._loading.discard(session.id)
raise
async def load_worker(
@@ -281,7 +275,10 @@ class SessionManager:
raise ValueError(f"Session '{session_id}' not found")
await self._load_worker_core(
session, agent_path, worker_id=worker_id, model=model,
session,
agent_path,
worker_id=worker_id,
model=model,
)
# Start judge + notify queen (skip for hive_coder itself)
@@ -289,6 +286,9 @@ class SessionManager:
await self._start_judge(session, session.runner._storage_path)
await self._notify_queen_worker_loaded(session)
# Emit SSE event so the frontend can update UI
await self._emit_worker_loaded(session)
return session
async def unload_worker(self, session_id: str) -> bool:
@@ -361,6 +361,7 @@ class SessionManager:
self,
session: Session,
worker_identity: str | None,
initial_prompt: str | None = None,
) -> None:
"""Start the queen executor for a session."""
from framework.agents.hive_coder.agent import (
@@ -372,7 +373,7 @@ class SessionManager:
from framework.runtime.core import Runtime
hive_home = Path.home() / ".hive"
queen_dir = hive_home / "queen" / "session" / session._session_id
queen_dir = hive_home / "queen" / "session" / session.id
queen_dir.mkdir(parents=True, exist_ok=True)
# Register MCP coding tools
@@ -395,7 +396,9 @@ class SessionManager:
register_queen_lifecycle_tools(
queen_registry,
session=session,
session_id=session._session_id,
session_id=session.id,
session_manager=self,
manager_session_id=session.id,
)
# Monitoring tools need concrete worker paths — only register when present
@@ -463,8 +466,8 @@ class SessionManager:
await executor.execute(
graph=queen_graph,
goal=queen_goal,
input_data={"greeting": "Session started."},
session_state={"resume_session_id": session._session_id},
input_data={"greeting": initial_prompt or "Session started."},
session_state={"resume_session_id": session.id},
)
logger.warning("Queen executor returned (should be forever-alive)")
except Exception:
@@ -504,7 +507,7 @@ class SessionManager:
)
hive_home = Path.home() / ".hive"
judge_dir = hive_home / "judge" / "session" / session._session_id
judge_dir = hive_home / "judge" / "session" / session.id
judge_dir.mkdir(parents=True, exist_ok=True)
judge_runtime = Runtime(hive_home / "judge")
@@ -512,12 +515,10 @@ class SessionManager:
monitoring_executor = monitoring_registry.get_executor()
async def _judge_loop():
interval = 120
first = True
interval = 300 # 5 minutes between checks
# Wait before the first check — let the worker actually do something
await asyncio.sleep(interval)
while True:
if not first:
await asyncio.sleep(interval)
first = False
try:
executor = GraphExecutor(
runtime=judge_runtime,
@@ -535,10 +536,11 @@ class SessionManager:
input_data={
"event": {"source": "timer", "reason": "scheduled"},
},
session_state={"resume_session_id": session._session_id},
session_state={"resume_session_id": session.id},
)
except Exception:
logger.error("Health judge tick failed", exc_info=True)
await asyncio.sleep(interval)
session.judge_task = asyncio.create_task(_judge_loop())
@@ -600,9 +602,32 @@ class SessionManager:
if node is None or not hasattr(node, "inject_event"):
return
profile = build_worker_profile(session.worker_runtime)
profile = build_worker_profile(
session.worker_runtime,
agent_path=session.worker_path,
storage_path=session.runner._storage_path if session.runner else None,
)
await node.inject_event(f"[SYSTEM] Worker loaded.{profile}")
async def _emit_worker_loaded(self, session: Session) -> None:
"""Publish a WORKER_LOADED event so the frontend can update."""
from framework.runtime.event_bus import AgentEvent, EventType
info = session.worker_info
await session.event_bus.publish(
AgentEvent(
type=EventType.WORKER_LOADED,
stream_id="queen",
data={
"worker_id": session.worker_id,
"worker_name": info.name if info else session.worker_id,
"agent_path": str(session.worker_path) if session.worker_path else "",
"goal": info.goal_name if info else "",
"node_count": info.node_count if info else 0,
},
)
)
async def _notify_queen_worker_unloaded(self, session: Session) -> None:
"""Notify the queen that the worker has been unloaded."""
executor = session.queen_executor
@@ -641,8 +666,8 @@ class SessionManager:
return s
return self.get_session_by_worker_id(agent_id)
def is_loading(self, worker_id: str) -> bool:
return worker_id in self._loading
def is_loading(self, session_id: str) -> bool:
return session_id in self._loading
def list_sessions(self) -> list[Session]:
return list(self._sessions.values())
+89 -96
View File
@@ -13,8 +13,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from aiohttp.test_utils import TestClient, TestServer
from framework.server.session_manager import Session
from framework.server.app import create_app
from framework.server.session_manager import Session
# ---------------------------------------------------------------------------
# Mock helpers
@@ -337,108 +337,97 @@ class TestHealth:
assert data["sessions"] == 0
class TestAgentCRUD:
class TestSessionCRUD:
@pytest.mark.asyncio
async def test_list_agents_empty(self):
async def test_list_sessions_empty(self):
app = create_app()
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents")
resp = await client.get("/api/sessions")
assert resp.status == 200
data = await resp.json()
assert data["agents"] == []
assert data["sessions"] == []
@pytest.mark.asyncio
async def test_list_agents_with_loaded(self):
async def test_list_sessions_with_loaded(self):
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents")
resp = await client.get("/api/sessions")
assert resp.status == 200
data = await resp.json()
assert len(data["agents"]) == 1
assert data["agents"][0]["id"] == "test_agent"
assert data["agents"][0]["intro_message"] == "Test intro"
assert len(data["sessions"]) == 1
assert data["sessions"][0]["session_id"] == "test_agent"
assert data["sessions"][0]["intro_message"] == "Test intro"
@pytest.mark.asyncio
async def test_get_agent_found(self):
async def test_get_session_found(self):
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent")
resp = await client.get("/api/sessions/test_agent")
assert resp.status == 200
data = await resp.json()
assert data["id"] == "test_agent"
assert data["session_id"] == "test_agent"
assert data["has_worker"] is True
assert "entry_points" in data
assert "graphs" in data
@pytest.mark.asyncio
async def test_get_agent_not_found(self):
async def test_get_session_not_found(self):
app = create_app()
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/nonexistent")
resp = await client.get("/api/sessions/nonexistent")
assert resp.status == 404
@pytest.mark.asyncio
async def test_get_agent_loading(self):
"""GET /api/agents/{id} returns 202 when agent is mid-load."""
app = create_app()
app["manager"]._loading.add("loading_agent")
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/loading_agent")
assert resp.status == 202
data = await resp.json()
assert data["id"] == "loading_agent"
assert data["loading"] is True
@pytest.mark.asyncio
async def test_unload_agent(self):
async def test_stop_session(self):
session = _make_session()
session.runner.cleanup_async = AsyncMock()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.delete("/api/agents/test_agent")
resp = await client.delete("/api/sessions/test_agent")
assert resp.status == 200
data = await resp.json()
assert data["unloaded"] == "test_agent"
assert data["stopped"] is True
# Verify it's gone
resp2 = await client.get("/api/agents/test_agent")
resp2 = await client.get("/api/sessions/test_agent")
assert resp2.status == 404
@pytest.mark.asyncio
async def test_unload_agent_not_found(self):
async def test_stop_session_not_found(self):
app = create_app()
async with TestClient(TestServer(app)) as client:
resp = await client.delete("/api/agents/nonexistent")
resp = await client.delete("/api/sessions/nonexistent")
assert resp.status == 404
@pytest.mark.asyncio
async def test_stats(self):
async def test_session_stats(self):
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/stats")
resp = await client.get("/api/sessions/test_agent/stats")
assert resp.status == 200
data = await resp.json()
assert data["running"] is True
@pytest.mark.asyncio
async def test_entry_points(self):
async def test_session_entry_points(self):
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/entry-points")
resp = await client.get("/api/sessions/test_agent/entry-points")
assert resp.status == 200
data = await resp.json()
assert len(data["entry_points"]) == 1
assert data["entry_points"][0]["id"] == "default"
@pytest.mark.asyncio
async def test_graphs(self):
async def test_session_graphs(self):
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/graphs")
resp = await client.get("/api/sessions/test_agent/graphs")
assert resp.status == 200
data = await resp.json()
assert "primary" in data["graphs"]
@@ -451,7 +440,7 @@ class TestExecution:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/trigger",
"/api/sessions/test_agent/trigger",
json={"entry_point_id": "default", "input_data": {"msg": "hi"}},
)
assert resp.status == 200
@@ -463,7 +452,7 @@ class TestExecution:
app = create_app()
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/nope/trigger",
"/api/sessions/nope/trigger",
json={"entry_point_id": "default"},
)
assert resp.status == 404
@@ -474,7 +463,7 @@ class TestExecution:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/inject",
"/api/sessions/test_agent/inject",
json={"node_id": "node_a", "content": "answer"},
)
assert resp.status == 200
@@ -487,7 +476,7 @@ class TestExecution:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/inject",
"/api/sessions/test_agent/inject",
json={"content": "answer"},
)
assert resp.status == 400
@@ -499,7 +488,7 @@ class TestExecution:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/chat",
"/api/sessions/test_agent/chat",
json={"message": "hello"},
)
assert resp.status == 200
@@ -515,7 +504,7 @@ class TestExecution:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/chat",
"/api/sessions/test_agent/chat",
json={"message": "user reply"},
)
assert resp.status == 200
@@ -531,7 +520,7 @@ class TestExecution:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/chat",
"/api/sessions/test_agent/chat",
json={"message": "hello"},
)
assert resp.status == 503
@@ -542,7 +531,7 @@ class TestExecution:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/chat",
"/api/sessions/test_agent/chat",
json={"message": ""},
)
assert resp.status == 400
@@ -553,7 +542,7 @@ class TestExecution:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/pause",
"/api/sessions/test_agent/pause",
json={"execution_id": "nonexistent"},
)
assert resp.status == 404
@@ -564,7 +553,7 @@ class TestExecution:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/pause",
"/api/sessions/test_agent/pause",
json={},
)
assert resp.status == 400
@@ -574,7 +563,7 @@ class TestExecution:
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/goal-progress")
resp = await client.get("/api/sessions/test_agent/goal-progress")
assert resp.status == 200
data = await resp.json()
assert data["progress"] == 0.5
@@ -592,7 +581,7 @@ class TestResume:
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/resume",
"/api/sessions/test_agent/resume",
json={"session_id": session_id},
)
assert resp.status == 200
@@ -612,7 +601,7 @@ class TestResume:
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/resume",
"/api/sessions/test_agent/resume",
json={
"session_id": session_id,
"checkpoint_id": "cp_node_complete_node_a_001",
@@ -628,7 +617,7 @@ class TestResume:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/resume",
"/api/sessions/test_agent/resume",
json={},
)
assert resp.status == 400
@@ -639,7 +628,7 @@ class TestResume:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/resume",
"/api/sessions/test_agent/resume",
json={"session_id": "session_nonexistent"},
)
assert resp.status == 404
@@ -654,7 +643,7 @@ class TestStop:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/stop",
"/api/sessions/test_agent/stop",
json={"execution_id": "exec_abc"},
)
assert resp.status == 200
@@ -667,7 +656,7 @@ class TestStop:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/stop",
"/api/sessions/test_agent/stop",
json={"execution_id": "nonexistent"},
)
assert resp.status == 404
@@ -678,7 +667,7 @@ class TestStop:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/stop",
"/api/sessions/test_agent/stop",
json={},
)
assert resp.status == 400
@@ -695,7 +684,7 @@ class TestReplay:
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/replay",
"/api/sessions/test_agent/replay",
json={
"session_id": session_id,
"checkpoint_id": "cp_node_complete_node_a_001",
@@ -712,13 +701,13 @@ class TestReplay:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/replay",
"/api/sessions/test_agent/replay",
json={"session_id": "s1"},
)
assert resp.status == 400 # missing checkpoint_id
resp2 = await client.post(
"/api/agents/test_agent/replay",
"/api/sessions/test_agent/replay",
json={"checkpoint_id": "cp1"},
)
assert resp2.status == 400 # missing session_id
@@ -733,7 +722,7 @@ class TestReplay:
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/agents/test_agent/replay",
"/api/sessions/test_agent/replay",
json={
"session_id": session_id,
"checkpoint_id": "nonexistent_cp",
@@ -742,7 +731,7 @@ class TestReplay:
assert resp.status == 404
class TestSessions:
class TestWorkerSessions:
@pytest.mark.asyncio
async def test_list_sessions(self, sample_session, tmp_agent_dir):
session_id, session_dir, state = sample_session
@@ -752,7 +741,7 @@ class TestSessions:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/sessions")
resp = await client.get("/api/sessions/test_agent/worker-sessions")
assert resp.status == 200
data = await resp.json()
assert len(data["sessions"]) == 1
@@ -767,7 +756,7 @@ class TestSessions:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/sessions")
resp = await client.get("/api/sessions/test_agent/worker-sessions")
assert resp.status == 200
data = await resp.json()
assert data["sessions"] == []
@@ -781,7 +770,7 @@ class TestSessions:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get(f"/api/agents/test_agent/sessions/{session_id}")
resp = await client.get(f"/api/sessions/test_agent/worker-sessions/{session_id}")
assert resp.status == 200
data = await resp.json()
assert data["status"] == "paused"
@@ -794,7 +783,7 @@ class TestSessions:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/sessions/nonexistent")
resp = await client.get("/api/sessions/test_agent/worker-sessions/nonexistent")
assert resp.status == 404
@pytest.mark.asyncio
@@ -806,7 +795,7 @@ class TestSessions:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.delete(f"/api/agents/test_agent/sessions/{session_id}")
resp = await client.delete(f"/api/sessions/test_agent/worker-sessions/{session_id}")
assert resp.status == 200
data = await resp.json()
assert data["deleted"] == session_id
@@ -821,7 +810,7 @@ class TestSessions:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.delete("/api/agents/test_agent/sessions/nonexistent")
resp = await client.delete("/api/sessions/test_agent/worker-sessions/nonexistent")
assert resp.status == 404
@pytest.mark.asyncio
@@ -833,7 +822,9 @@ class TestSessions:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get(f"/api/agents/test_agent/sessions/{session_id}/checkpoints")
resp = await client.get(
f"/api/sessions/test_agent/worker-sessions/{session_id}/checkpoints"
)
assert resp.status == 200
data = await resp.json()
assert len(data["checkpoints"]) == 1
@@ -852,7 +843,7 @@ class TestSessions:
async with TestClient(TestServer(app)) as client:
resp = await client.post(
f"/api/agents/test_agent/sessions/{session_id}"
f"/api/sessions/test_agent/worker-sessions/{session_id}"
"/checkpoints/cp_node_complete_node_a_001/restore"
)
assert resp.status == 200
@@ -871,7 +862,7 @@ class TestSessions:
async with TestClient(TestServer(app)) as client:
resp = await client.post(
f"/api/agents/test_agent/sessions/{session_id}/checkpoints/nonexistent_cp/restore"
f"/api/sessions/test_agent/worker-sessions/{session_id}/checkpoints/nonexistent_cp/restore"
)
assert resp.status == 404
@@ -886,7 +877,9 @@ class TestMessages:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get(f"/api/agents/test_agent/sessions/{session_id}/messages")
resp = await client.get(
f"/api/sessions/test_agent/worker-sessions/{session_id}/messages"
)
assert resp.status == 200
data = await resp.json()
msgs = data["messages"]
@@ -910,7 +903,7 @@ class TestMessages:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/sessions/{session_id}/messages?node_id=node_a"
f"/api/sessions/test_agent/worker-sessions/{session_id}/messages?node_id=node_a"
)
assert resp.status == 200
data = await resp.json()
@@ -932,7 +925,7 @@ class TestMessages:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/sessions/{worker_session_id}/messages"
f"/api/sessions/test_agent/worker-sessions/{worker_session_id}/messages"
)
assert resp.status == 200
data = await resp.json()
@@ -1002,7 +995,7 @@ class TestMessages:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/sessions/{worker_session_id}/messages?client_only=true"
f"/api/sessions/test_agent/worker-sessions/{worker_session_id}/messages?client_only=true"
)
assert resp.status == 200
msgs = (await resp.json())["messages"]
@@ -1037,11 +1030,11 @@ class TestMessages:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/sessions/{worker_session_id}/messages?client_only=true"
f"/api/sessions/test_agent/worker-sessions/{worker_session_id}/messages?client_only=true"
)
assert resp.status == 200
msgs = (await resp.json())["messages"]
# No runner can't resolve client-facing nodes returns all messages
# No runner -> can't resolve client-facing nodes -> returns all messages
assert len(msgs) == 2
@@ -1053,7 +1046,7 @@ class TestGraphNodes:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/graphs/primary/nodes")
resp = await client.get("/api/sessions/test_agent/graphs/primary/nodes")
assert resp.status == 200
data = await resp.json()
assert len(data["nodes"]) == 2
@@ -1073,7 +1066,7 @@ class TestGraphNodes:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/graphs/primary/nodes")
resp = await client.get("/api/sessions/test_agent/graphs/primary/nodes")
assert resp.status == 200
data = await resp.json()
@@ -1105,7 +1098,7 @@ class TestGraphNodes:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/graphs/primary/nodes?session_id={session_id}"
f"/api/sessions/test_agent/graphs/primary/nodes?session_id={session_id}"
)
assert resp.status == 200
data = await resp.json()
@@ -1121,7 +1114,7 @@ class TestGraphNodes:
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/graphs/nonexistent/nodes")
resp = await client.get("/api/sessions/test_agent/graphs/nonexistent/nodes")
assert resp.status == 404
@pytest.mark.asyncio
@@ -1131,7 +1124,7 @@ class TestGraphNodes:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/graphs/primary/nodes/node_a")
resp = await client.get("/api/sessions/test_agent/graphs/primary/nodes/node_a")
assert resp.status == 200
data = await resp.json()
assert data["id"] == "node_a"
@@ -1151,7 +1144,7 @@ class TestGraphNodes:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/graphs/primary/nodes/node_a")
resp = await client.get("/api/sessions/test_agent/graphs/primary/nodes/node_a")
assert resp.status == 200
data = await resp.json()
assert "system_prompt" in data
@@ -1160,7 +1153,7 @@ class TestGraphNodes:
)
# Node without system_prompt should return empty string
resp2 = await client.get("/api/agents/test_agent/graphs/primary/nodes/node_b")
resp2 = await client.get("/api/sessions/test_agent/graphs/primary/nodes/node_b")
assert resp2.status == 200
data2 = await resp2.json()
assert data2["system_prompt"] == ""
@@ -1172,7 +1165,7 @@ class TestGraphNodes:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/graphs/primary/nodes/nonexistent")
resp = await client.get("/api/sessions/test_agent/graphs/primary/nodes/nonexistent")
assert resp.status == 404
@@ -1184,7 +1177,7 @@ class TestNodeCriteria:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/graphs/primary/nodes/node_a/criteria")
resp = await client.get("/api/sessions/test_agent/graphs/primary/nodes/node_a/criteria")
assert resp.status == 200
data = await resp.json()
assert data["node_id"] == "node_a"
@@ -1215,7 +1208,7 @@ class TestNodeCriteria:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/graphs/primary/nodes/node_b/criteria"
f"/api/sessions/test_agent/graphs/primary/nodes/node_b/criteria"
f"?session_id={session_id}"
)
assert resp.status == 200
@@ -1234,7 +1227,7 @@ class TestNodeCriteria:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
"/api/agents/test_agent/graphs/primary/nodes/nonexistent/criteria"
"/api/sessions/test_agent/graphs/primary/nodes/nonexistent/criteria"
)
assert resp.status == 404
@@ -1248,7 +1241,7 @@ class TestLogs:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/logs")
resp = await client.get("/api/sessions/test_agent/logs")
assert resp.status == 404
@pytest.mark.asyncio
@@ -1266,7 +1259,7 @@ class TestLogs:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/logs")
resp = await client.get("/api/sessions/test_agent/logs")
assert resp.status == 200
data = await resp.json()
assert "logs" in data
@@ -1289,7 +1282,7 @@ class TestLogs:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/logs?session_id={session_id}&level=summary"
f"/api/sessions/test_agent/logs?session_id={session_id}&level=summary"
)
assert resp.status == 200
data = await resp.json()
@@ -1312,7 +1305,7 @@ class TestLogs:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/logs?session_id={session_id}&level=details"
f"/api/sessions/test_agent/logs?session_id={session_id}&level=details"
)
assert resp.status == 200
data = await resp.json()
@@ -1336,7 +1329,7 @@ class TestLogs:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/logs?session_id={session_id}&level=tools"
f"/api/sessions/test_agent/logs?session_id={session_id}&level=tools"
)
assert resp.status == 200
data = await resp.json()
@@ -1364,7 +1357,7 @@ class TestNodeLogs:
async with TestClient(TestServer(app)) as client:
resp = await client.get(
f"/api/agents/test_agent/graphs/primary/nodes/node_a/logs?session_id={session_id}"
f"/api/sessions/test_agent/graphs/primary/nodes/node_a/logs?session_id={session_id}"
)
assert resp.status == 200
data = await resp.json()
@@ -1387,7 +1380,7 @@ class TestNodeLogs:
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/api/agents/test_agent/graphs/primary/nodes/node_a/logs")
resp = await client.get("/api/sessions/test_agent/graphs/primary/nodes/node_a/logs")
assert resp.status == 400
@@ -1495,7 +1488,7 @@ class TestCredentials:
class TestSSEFormat:
"""Tests for SSE event wire format events must be unnamed (data-only)
"""Tests for SSE event wire format -- events must be unnamed (data-only)
so the frontend's es.onmessage handler receives them."""
@pytest.mark.asyncio
+118 -4
View File
@@ -13,7 +13,7 @@ Usage::
register_queen_lifecycle_tools(
registry=queen_tool_registry,
session=session,
session_id=session._session_id,
session_id=session.id,
)
# TUI path — wrap bare references in an adapter
@@ -60,17 +60,28 @@ class WorkerSessionAdapter:
worker_path: Path | None = None
def build_worker_profile(runtime: AgentRuntime) -> str:
def build_worker_profile(
runtime: AgentRuntime,
*,
agent_path: Path | str | None = None,
storage_path: Path | str | None = None,
) -> str:
"""Build a worker capability profile from its graph/goal definition.
Injected into the queen's system prompt so it knows what the worker
can and cannot do enabling correct delegation decisions.
Args:
runtime: The worker's AgentRuntime.
agent_path: Path to the agent source directory (e.g. exports/my_agent).
storage_path: Path to runtime storage (e.g. ~/.hive/agents/my_agent).
"""
graph = runtime.graph
goal = runtime.goal
agent_name = runtime.graph_id
lines = ["\n\n# Worker Profile"]
lines.append(f"Agent: {runtime.graph_id}")
lines.append(f"Agent: {agent_name}")
lines.append(f"Goal: {goal.name}")
if goal.description:
lines.append(f"Description: {goal.description}")
@@ -97,6 +108,26 @@ def build_worker_profile(runtime: AgentRuntime) -> str:
if all_tools:
lines.append(f"\n## Worker Tools\n{', '.join(sorted(all_tools))}")
# Operational context — paths and inspection guidance
lines.append("\n## Worker Runtime Context")
if agent_path:
lines.append(f"- Source: {agent_path}")
if storage_path:
lines.append(f"- Storage: {storage_path}")
lines.append(f"- Sessions: {storage_path}/sessions/")
lines.append(f"- Logs: {{session_dir}}/logs/ (summary.json, details.jsonl, tool_logs.jsonl)")
lines.append(f"- Checkpoints: {{session_dir}}/checkpoints/")
lines.append(f"\n## Inspecting This Worker")
lines.append(f'- list_agent_sessions("{agent_name}") — find sessions')
lines.append(f'- get_agent_session_state("{agent_name}", "{{session_id}}") — see status')
lines.append(f'- get_agent_session_memory("{agent_name}", "{{session_id}}") — inspect data')
lines.append(f'- list_agent_checkpoints("{agent_name}", "{{session_id}}") — trace execution')
lines.append(f'- get_agent_checkpoint("{agent_name}", "{{session_id}}") — load checkpoint')
if agent_path:
lines.append(f'- read_file("{agent_path}/agent.py") — read agent source')
lines.append(f'- read_file("{agent_path}/nodes/__init__.py") — read node definitions')
lines.append("\nStatus at session start: idle (not started).")
return "\n".join(lines)
@@ -109,6 +140,9 @@ def register_queen_lifecycle_tools(
worker_runtime: AgentRuntime | None = None,
event_bus: EventBus | None = None,
storage_path: Path | None = None,
# Server context — enables load_built_agent tool
session_manager: Any = None,
manager_session_id: str | None = None,
) -> int:
"""Register queen lifecycle tools.
@@ -121,6 +155,10 @@ def register_queen_lifecycle_tools(
worker_runtime: (Legacy) Direct runtime reference. If ``session``
is not provided, a WorkerSessionAdapter is created from
worker_runtime + event_bus + storage_path.
session_manager: (Server only) The SessionManager instance, needed
for ``load_built_agent`` to hot-load a worker.
manager_session_id: (Server only) The session's ID in the manager,
used with ``session_manager.load_worker()``.
Returns the number of tools registered.
"""
@@ -155,6 +193,9 @@ def register_queen_lifecycle_tools(
return json.dumps({"error": "No worker loaded in this session."})
try:
# Resume timers in case they were paused by a previous stop_worker
runtime.resume_timers()
# Get session state from any prior execution for memory continuity
session_state = runtime._get_primary_session_state("default") or {}
@@ -226,17 +267,21 @@ def register_queen_lifecycle_tools(
except Exception as e:
logger.warning("Failed to cancel %s: %s", exec_id, e)
# Pause timers so the next tick doesn't restart execution
runtime.pause_timers()
return json.dumps(
{
"status": "stopped" if cancelled else "no_active_executions",
"cancelled": cancelled,
"timers_paused": True,
}
)
_stop_tool = Tool(
name="stop_worker",
description=(
"Cancel the worker agent's active execution. "
"Cancel the worker agent's active execution and pause its timers. "
"The worker stops gracefully. No parameters needed."
),
parameters={"type": "object", "properties": {}},
@@ -373,5 +418,74 @@ def register_queen_lifecycle_tools(
)
tools_registered += 1
# --- load_built_agent (server context only) --------------------------------
if session_manager is not None and manager_session_id is not None:
async def load_built_agent(agent_path: str) -> str:
"""Load a newly built agent as the worker in this session.
After building and validating an agent, call this to make it
available immediately. The user will see the agent's graph and
can interact with it without opening a new tab.
"""
runtime = _get_runtime()
if runtime is not None:
return json.dumps(
{
"error": "A worker is already loaded in this session. "
"Unload it first or open a new tab."
}
)
resolved_path = Path(agent_path).resolve()
if not resolved_path.exists():
return json.dumps({"error": f"Agent path does not exist: {resolved_path}"})
try:
updated_session = await session_manager.load_worker(
manager_session_id,
str(resolved_path),
)
info = updated_session.worker_info
return json.dumps(
{
"status": "loaded",
"worker_id": updated_session.worker_id,
"worker_name": info.name if info else updated_session.worker_id,
"goal": info.goal_name if info else "",
"node_count": info.node_count if info else 0,
}
)
except Exception as e:
logger.error("load_built_agent failed for '%s'", agent_path, exc_info=True)
return json.dumps({"error": f"Failed to load agent: {e}"})
_load_built_tool = Tool(
name="load_built_agent",
description=(
"Load a newly built agent as the worker in this session. "
"After building and validating an agent, call this with the agent's "
"path (e.g. 'exports/my_agent') to make it available immediately. "
"The user will see the agent's graph and can interact with it."
),
parameters={
"type": "object",
"properties": {
"agent_path": {
"type": "string",
"description": ("Path to the agent directory (e.g. 'exports/my_agent')"),
},
},
"required": ["agent_path"],
},
)
registry.register(
"load_built_agent",
_load_built_tool,
lambda inputs: load_built_agent(**inputs),
)
tools_registered += 1
logger.info("Registered %d queen lifecycle tools", tools_registered)
return tools_registered
+6 -1
View File
@@ -605,7 +605,12 @@ class AdenTUI(App):
# Build worker profile for queen's system prompt.
from framework.tools.queen_lifecycle_tools import build_worker_profile
worker_identity = build_worker_profile(self.runtime)
worker_identity = build_worker_profile(
self.runtime,
agent_path=getattr(self, '_runner', None) and self._runner.agent_path,
storage_path=storage_path,
)
# Adjust queen graph: filter tools to what's registered and
# append worker identity to the system prompt.
+61 -19
View File
@@ -38,6 +38,7 @@ class AgentEntry:
tool_count: int = 0
tags: list[str] = field(default_factory=list)
last_active: str | None = None
version: str | None = None
def _get_last_active(agent_name: str) -> str | None:
@@ -70,19 +71,50 @@ def _count_sessions(agent_name: str) -> int:
return sum(1 for d in sessions_dir.iterdir() if d.is_dir() and d.name.startswith("session_"))
def _extract_agent_stats(agent_json_path: Path) -> tuple[int, int, list[str]]:
"""Extract node count, tool count, and tags from agent.json."""
try:
data = json.loads(agent_json_path.read_text())
nodes = data.get("nodes", [])
node_count = len(nodes)
tools: set[str] = set()
for node in nodes:
tools.update(node.get("tools", []))
tags = data.get("agent", {}).get("tags", [])
return node_count, len(tools), tags
except Exception:
return 0, 0, []
def _extract_agent_stats(agent_path: Path) -> tuple[int, int, list[str]]:
"""Extract node count, tool count, and tags from an agent directory.
Prefers agent.py (AST-parsed) over agent.json for node/tool counts
since agent.json may be stale. Tags are only available from agent.json.
"""
import ast
node_count, tool_count, tags = 0, 0, []
# Try agent.py first — source of truth for nodes
agent_py = agent_path / "agent.py"
if agent_py.exists():
try:
tree = ast.parse(agent_py.read_text())
for node in ast.walk(tree):
# Find `nodes = [...]` assignment
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "nodes":
if isinstance(node.value, ast.List):
node_count = len(node.value.elts)
except Exception:
pass
# Fall back to / supplement from agent.json
agent_json = agent_path / "agent.json"
if agent_json.exists():
try:
data = json.loads(agent_json.read_text())
json_nodes = data.get("nodes", [])
if node_count == 0:
node_count = len(json_nodes)
# Tool count: use whichever source gave us nodes, but agent.json
# has the structured tool lists so prefer it for tool counting
tools: set[str] = set()
for n in json_nodes:
tools.update(n.get("tools", []))
tool_count = len(tools)
tags = data.get("agent", {}).get("tags", [])
except Exception:
pass
return node_count, tool_count, tags
def discover_agents() -> dict[str, list[AgentEntry]]:
@@ -113,12 +145,11 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
config_fallback_name = path.name.replace("_", " ").title()
used_config = name != config_fallback_name
agent_json = path / "agent.json"
node_count, tool_count, tags = 0, 0, []
if agent_json.exists():
node_count, tool_count, tags = _extract_agent_stats(agent_json)
if not used_config:
# config.py didn't provide values, fall back to agent.json
node_count, tool_count, tags = _extract_agent_stats(path)
if not used_config:
# config.py didn't provide values, fall back to agent.json
agent_json = path / "agent.json"
if agent_json.exists():
try:
data = json.loads(agent_json.read_text())
meta = data.get("agent", {})
@@ -127,6 +158,16 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
except Exception:
pass
# Get version from git if available
version = None
try:
from framework.utils.git import is_git_repo, latest_version
if is_git_repo(path):
version = latest_version(path)
except Exception:
pass
entries.append(
AgentEntry(
path=path,
@@ -138,6 +179,7 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
tool_count=tool_count,
tags=tags,
last_active=_get_last_active(path.name),
version=version,
)
)
if entries:
+454
View File
@@ -0,0 +1,454 @@
"""Git utilities for per-agent version management.
Each agent in exports/{agent_name}/ can have its own local git repository
for tracking changes, versioning with semver tags, and inspecting history.
All operations use subprocess no external git library dependency.
"""
import logging
import os
import re
import subprocess
import time
from pathlib import Path
logger = logging.getLogger(__name__)
# Semver pattern: v{major}.{minor}.{patch} with optional pre-release
_SEMVER_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
_AGENT_GITIGNORE = """\
__pycache__/
*.pyc
*.pyo
.DS_Store
"""
# ---------------------------------------------------------------------------
# Core primitive
# ---------------------------------------------------------------------------
def git_run(
repo_dir: Path,
*args: str,
timeout: int = 30,
check: bool = False,
) -> subprocess.CompletedProcess:
"""Execute a git command inside *repo_dir*.
Returns the CompletedProcess. Raises RuntimeError if git is not installed.
"""
try:
return subprocess.run(
["git", *args],
cwd=str(repo_dir),
capture_output=True,
text=True,
timeout=timeout,
check=check,
)
except FileNotFoundError:
raise RuntimeError(
"git is not installed or not on PATH. "
"Agent versioning requires git."
)
except subprocess.TimeoutExpired:
logger.warning("git command timed out: git %s (in %s)", " ".join(args), repo_dir)
raise
# ---------------------------------------------------------------------------
# Repo lifecycle
# ---------------------------------------------------------------------------
def is_git_repo(agent_dir: Path) -> bool:
"""Return True if *agent_dir* contains a .git directory."""
try:
return (agent_dir / ".git").is_dir()
except Exception:
return False
def init_repo(agent_dir: Path) -> None:
"""Idempotent git-init for an agent directory.
Creates .gitignore, and if files already exist, makes an initial commit.
"""
if is_git_repo(agent_dir):
return
git_run(agent_dir, "init", check=True)
# Write .gitignore
gitignore = agent_dir / ".gitignore"
if not gitignore.exists():
gitignore.write_text(_AGENT_GITIGNORE)
# If there are existing files, create an initial commit
result = git_run(agent_dir, "status", "--porcelain")
if result.stdout.strip():
git_run(agent_dir, "add", "-A")
git_run(agent_dir, "commit", "-m", "Initial commit")
# ---------------------------------------------------------------------------
# Commit operations
# ---------------------------------------------------------------------------
def has_changes(agent_dir: Path) -> bool:
"""Return True if the working tree has uncommitted changes."""
if not is_git_repo(agent_dir):
return False
result = git_run(agent_dir, "status", "--porcelain")
return bool(result.stdout.strip())
def commit_all(agent_dir: Path, message: str) -> str | None:
"""Stage all changes and commit.
Returns the commit SHA on success, or None if there was nothing to commit.
"""
if not is_git_repo(agent_dir):
logger.warning("commit_all called on non-git dir: %s", agent_dir)
return None
# Check for changes first
result = git_run(agent_dir, "status", "--porcelain")
if not result.stdout.strip():
return None
git_run(agent_dir, "add", "-A")
result = git_run(agent_dir, "commit", "-m", message)
if result.returncode != 0:
# Retry once on lock contention
if "index.lock" in (result.stderr or ""):
time.sleep(0.5)
result = git_run(agent_dir, "commit", "-m", message)
if result.returncode != 0:
logger.warning("git commit failed: %s", result.stderr)
return None
sha_result = git_run(agent_dir, "rev-parse", "HEAD")
return sha_result.stdout.strip() or None
# ---------------------------------------------------------------------------
# Tag / version operations
# ---------------------------------------------------------------------------
def parse_semver(tag: str) -> tuple[int, int, int] | None:
"""Parse a semver tag like 'v1.2.3' into (major, minor, patch).
Returns None if the tag doesn't match semver format.
"""
m = _SEMVER_RE.match(tag)
if not m:
return None
return int(m.group(1)), int(m.group(2)), int(m.group(3))
def create_tag(
agent_dir: Path,
version: str,
message: str = "",
) -> None:
"""Create an annotated git tag with semver validation.
Raises ValueError if the version format is invalid or already exists.
"""
if parse_semver(version) is None:
raise ValueError(
f"Invalid semver tag '{version}'. Expected format: v{{major}}.{{minor}}.{{patch}} (e.g. v1.0.0)"
)
if tag_exists(agent_dir, version):
raise ValueError(f"Tag '{version}' already exists")
msg = message or version
result = git_run(agent_dir, "tag", "-a", version, "-m", msg)
if result.returncode != 0:
raise RuntimeError(f"Failed to create tag: {result.stderr}")
def delete_tag(agent_dir: Path, tag: str) -> None:
"""Delete a git tag."""
result = git_run(agent_dir, "tag", "-d", tag)
if result.returncode != 0:
raise ValueError(f"Tag '{tag}' not found or could not be deleted")
def tag_exists(agent_dir: Path, tag: str) -> bool:
"""Check if a tag exists."""
result = git_run(agent_dir, "tag", "-l", tag)
return tag in result.stdout.strip().split("\n")
def list_tags(agent_dir: Path) -> list[dict]:
"""List all tags sorted by semver descending.
Returns list of {tag, sha, date, message}.
"""
if not is_git_repo(agent_dir):
return []
result = git_run(
agent_dir,
"tag",
"-l",
"--format=%(refname:short)\t%(objectname:short)\t%(creatordate:iso-strict)\t%(contents:subject)",
)
if result.returncode != 0 or not result.stdout.strip():
return []
tags = []
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split("\t", 3)
if len(parts) < 2:
continue
tag_name = parts[0]
# Only include semver tags
if parse_semver(tag_name) is None:
continue
tags.append({
"tag": tag_name,
"sha": parts[1] if len(parts) > 1 else "",
"date": parts[2] if len(parts) > 2 else "",
"message": parts[3] if len(parts) > 3 else "",
})
# Sort by semver descending
tags.sort(key=lambda t: parse_semver(t["tag"]) or (0, 0, 0), reverse=True)
return tags
def latest_version(agent_dir: Path) -> str | None:
"""Return the latest semver tag, or None if no tags exist."""
tags = list_tags(agent_dir)
return tags[0]["tag"] if tags else None
def next_version(agent_dir: Path, bump: str = "patch") -> str:
"""Compute the next semver version based on the latest tag.
Args:
bump: One of 'patch', 'minor', 'major'.
Returns:
The next version string (e.g. 'v1.0.1').
"""
current = latest_version(agent_dir)
if current is None:
return "v0.1.0"
parsed = parse_semver(current)
if parsed is None:
return "v0.1.0"
major, minor, patch = parsed
if bump == "major":
return f"v{major + 1}.0.0"
elif bump == "minor":
return f"v{major}.{minor + 1}.0"
else: # patch
return f"v{major}.{minor}.{patch + 1}"
# ---------------------------------------------------------------------------
# History operations
# ---------------------------------------------------------------------------
def get_head(agent_dir: Path) -> dict | None:
"""Get current HEAD commit info.
Returns {sha, short_sha, message, date, author} or None.
"""
if not is_git_repo(agent_dir):
return None
result = git_run(
agent_dir,
"log",
"-1",
"--format=%H\t%h\t%s\t%aI\t%an",
)
if result.returncode != 0 or not result.stdout.strip():
return None
parts = result.stdout.strip().split("\t", 4)
if len(parts) < 5:
return None
return {
"sha": parts[0],
"short_sha": parts[1],
"message": parts[2],
"date": parts[3],
"author": parts[4],
}
def log(
agent_dir: Path,
limit: int = 50,
since_tag: str = "",
) -> list[dict]:
"""Get commit log.
Returns list of {sha, short_sha, message, date, author, tags}.
"""
if not is_git_repo(agent_dir):
return []
args = [
"log",
f"--max-count={limit}",
"--format=%H\t%h\t%s\t%aI\t%an\t%D",
]
if since_tag:
args.append(f"{since_tag}..HEAD")
result = git_run(agent_dir, *args)
if result.returncode != 0 or not result.stdout.strip():
return []
commits = []
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split("\t", 5)
if len(parts) < 5:
continue
# Extract tags from ref decoration
refs = parts[5] if len(parts) > 5 else ""
tags = []
if refs:
for ref in refs.split(","):
ref = ref.strip()
if ref.startswith("tag: "):
tags.append(ref[5:])
commits.append({
"sha": parts[0],
"short_sha": parts[1],
"message": parts[2],
"date": parts[3],
"author": parts[4],
"tags": tags,
})
return commits
# ---------------------------------------------------------------------------
# File inspection at specific refs
# ---------------------------------------------------------------------------
def show_file(agent_dir: Path, ref: str, file_path: str) -> str | None:
"""Read file content at a specific ref (tag, commit SHA, etc.).
Uses `git show ref:path`. Returns None if the file doesn't exist at that ref.
"""
if not is_git_repo(agent_dir):
return None
result = git_run(agent_dir, "show", f"{ref}:{file_path}")
if result.returncode != 0:
return None
return result.stdout
def list_files_at_ref(agent_dir: Path, ref: str = "HEAD") -> list[str]:
"""List all files tracked at a specific ref.
Returns a list of relative file paths.
"""
if not is_git_repo(agent_dir):
return []
result = git_run(agent_dir, "ls-tree", "-r", "--name-only", ref)
if result.returncode != 0:
return []
return [f for f in result.stdout.strip().split("\n") if f.strip()]
def diff_between(agent_dir: Path, ref_a: str, ref_b: str) -> str:
"""Get unified diff between two refs.
Returns the diff output as a string.
"""
if not is_git_repo(agent_dir):
return ""
result = git_run(agent_dir, "diff", ref_a, ref_b)
return result.stdout if result.returncode == 0 else ""
# ---------------------------------------------------------------------------
# Version export / extraction
# ---------------------------------------------------------------------------
def export_at_ref(
agent_dir: Path,
ref: str,
output_dir: Path | None = None,
) -> Path:
"""Extract the agent files at a specific ref to a directory.
Uses `git archive` for a clean extraction without .git metadata.
If output_dir is None, uses ~/.hive/versions/{agent_name}/{ref}/.
Returns the output directory path.
"""
if not is_git_repo(agent_dir):
raise ValueError(f"Not a git repo: {agent_dir}")
agent_name = agent_dir.name
if output_dir is None:
output_dir = Path.home() / ".hive" / "versions" / agent_name / ref
# Check if already extracted (cache hit)
if output_dir.exists() and any(output_dir.iterdir()):
# Verify the ref matches by checking a marker file
marker = output_dir / ".version_ref"
if marker.exists() and marker.read_text().strip() == ref:
return output_dir
output_dir.mkdir(parents=True, exist_ok=True)
# Extract via git archive piped to tar (binary mode, not text)
archive = subprocess.run(
["git", "archive", ref],
cwd=str(agent_dir),
capture_output=True,
timeout=30,
)
if archive.returncode != 0:
raise ValueError(f"Failed to archive ref '{ref}': {archive.stderr.decode()}")
extract = subprocess.run(
["tar", "xf", "-"],
cwd=str(output_dir),
input=archive.stdout,
capture_output=True,
)
if extract.returncode != 0:
raise RuntimeError(f"Failed to extract archive: {extract.stderr.decode()}")
# Write marker for cache validation
(output_dir / ".version_ref").write_text(ref)
return output_dir
+1 -29
View File
@@ -1,34 +1,6 @@
import { api } from "./client";
import type {
Agent,
AgentDetail,
DiscoverResult,
EntryPoint,
} from "./types";
import type { DiscoverResult } from "./types";
export const agentsApi = {
discover: () => api.get<DiscoverResult>("/discover"),
list: () => api.get<{ agents: Agent[] }>("/agents"),
load: (agentPath: string, agentId?: string, model?: string) =>
api.post<Agent>("/agents", {
agent_path: agentPath,
agent_id: agentId,
model,
}),
get: (agentId: string) => api.get<AgentDetail>(`/agents/${agentId}`),
unload: (agentId: string) =>
api.delete<{ unloaded: string }>(`/agents/${agentId}`),
stats: (agentId: string) =>
api.get<Record<string, unknown>>(`/agents/${agentId}/stats`),
entryPoints: (agentId: string) =>
api.get<{ entry_points: EntryPoint[] }>(`/agents/${agentId}/entry-points`),
graphs: (agentId: string) =>
api.get<{ graphs: string[] }>(`/agents/${agentId}/graphs`),
};
+6 -1
View File
@@ -17,6 +17,8 @@ export interface AgentCredentialRequirement {
tools: string[];
node_types: string[];
available: boolean;
valid: boolean | null;
validation_message: string | null;
direct_api_key_supported: boolean;
aden_supported: boolean;
credential_key: string;
@@ -39,8 +41,11 @@ export const credentialsApi = {
api.delete<{ deleted: boolean }>(`/credentials/${credentialId}`),
checkAgent: (agentPath: string) =>
api.post<{ required: AgentCredentialRequirement[] }>(
api.post<{ required: AgentCredentialRequirement[]; has_aden_key: boolean }>(
"/credentials/check-agent",
{ agent_path: agentPath },
),
saveAdenKey: (key: string) =>
api.post<{ saved: boolean }>("/credentials/aden-key", { key }),
};
+18 -18
View File
@@ -11,54 +11,54 @@ import type {
export const executionApi = {
trigger: (
agentId: string,
sessionId: string,
entryPointId: string,
inputData: Record<string, unknown>,
sessionState?: Record<string, unknown>,
) =>
api.post<TriggerResult>(`/agents/${agentId}/trigger`, {
api.post<TriggerResult>(`/sessions/${sessionId}/trigger`, {
entry_point_id: entryPointId,
input_data: inputData,
session_state: sessionState,
}),
inject: (
agentId: string,
sessionId: string,
nodeId: string,
content: string,
graphId?: string,
) =>
api.post<InjectResult>(`/agents/${agentId}/inject`, {
api.post<InjectResult>(`/sessions/${sessionId}/inject`, {
node_id: nodeId,
content,
graph_id: graphId,
}),
chat: (agentId: string, message: string) =>
api.post<ChatResult>(`/agents/${agentId}/chat`, { message }),
chat: (sessionId: string, message: string) =>
api.post<ChatResult>(`/sessions/${sessionId}/chat`, { message }),
stop: (agentId: string, executionId: string) =>
api.post<StopResult>(`/agents/${agentId}/stop`, {
stop: (sessionId: string, executionId: string) =>
api.post<StopResult>(`/sessions/${sessionId}/stop`, {
execution_id: executionId,
}),
pause: (agentId: string, executionId: string) =>
api.post<StopResult>(`/agents/${agentId}/pause`, {
pause: (sessionId: string, executionId: string) =>
api.post<StopResult>(`/sessions/${sessionId}/pause`, {
execution_id: executionId,
}),
resume: (agentId: string, sessionId: string, checkpointId?: string) =>
api.post<ResumeResult>(`/agents/${agentId}/resume`, {
session_id: sessionId,
resume: (sessionId: string, workerSessionId: string, checkpointId?: string) =>
api.post<ResumeResult>(`/sessions/${sessionId}/resume`, {
session_id: workerSessionId,
checkpoint_id: checkpointId,
}),
replay: (agentId: string, sessionId: string, checkpointId: string) =>
api.post<ReplayResult>(`/agents/${agentId}/replay`, {
session_id: sessionId,
replay: (sessionId: string, workerSessionId: string, checkpointId: string) =>
api.post<ReplayResult>(`/sessions/${sessionId}/replay`, {
session_id: workerSessionId,
checkpoint_id: checkpointId,
}),
goalProgress: (agentId: string) =>
api.get<GoalProgress>(`/agents/${agentId}/goal-progress`),
goalProgress: (sessionId: string) =>
api.get<GoalProgress>(`/sessions/${sessionId}/goal-progress`),
};
+9 -9
View File
@@ -2,28 +2,28 @@ import { api } from "./client";
import type { GraphTopology, NodeDetail, NodeCriteria, ToolInfo } from "./types";
export const graphsApi = {
nodes: (agentId: string, graphId: string, sessionId?: string) =>
nodes: (sessionId: string, graphId: string, workerSessionId?: string) =>
api.get<GraphTopology>(
`/agents/${agentId}/graphs/${graphId}/nodes${sessionId ? `?session_id=${sessionId}` : ""}`,
`/sessions/${sessionId}/graphs/${graphId}/nodes${workerSessionId ? `?session_id=${workerSessionId}` : ""}`,
),
node: (agentId: string, graphId: string, nodeId: string) =>
node: (sessionId: string, graphId: string, nodeId: string) =>
api.get<NodeDetail>(
`/agents/${agentId}/graphs/${graphId}/nodes/${nodeId}`,
`/sessions/${sessionId}/graphs/${graphId}/nodes/${nodeId}`,
),
nodeCriteria: (
agentId: string,
sessionId: string,
graphId: string,
nodeId: string,
sessionId?: string,
workerSessionId?: string,
) =>
api.get<NodeCriteria>(
`/agents/${agentId}/graphs/${graphId}/nodes/${nodeId}/criteria${sessionId ? `?session_id=${sessionId}` : ""}`,
`/sessions/${sessionId}/graphs/${graphId}/nodes/${nodeId}/criteria${workerSessionId ? `?session_id=${workerSessionId}` : ""}`,
),
nodeTools: (agentId: string, graphId: string, nodeId: string) =>
nodeTools: (sessionId: string, graphId: string, nodeId: string) =>
api.get<{ tools: ToolInfo[] }>(
`/agents/${agentId}/graphs/${graphId}/nodes/${nodeId}/tools`,
`/sessions/${sessionId}/graphs/${graphId}/nodes/${nodeId}/tools`,
),
};
+11 -11
View File
@@ -2,31 +2,31 @@ import { api } from "./client";
import type { LogEntry, LogNodeDetail, LogToolStep } from "./types";
export const logsApi = {
list: (agentId: string, limit?: number) =>
list: (sessionId: string, limit?: number) =>
api.get<{ logs: LogEntry[] }>(
`/agents/${agentId}/logs${limit ? `?limit=${limit}` : ""}`,
`/sessions/${sessionId}/logs${limit ? `?limit=${limit}` : ""}`,
),
summary: (agentId: string, sessionId: string) =>
summary: (sessionId: string, workerSessionId: string) =>
api.get<LogEntry>(
`/agents/${agentId}/logs?session_id=${sessionId}&level=summary`,
`/sessions/${sessionId}/logs?session_id=${workerSessionId}&level=summary`,
),
details: (agentId: string, sessionId: string) =>
details: (sessionId: string, workerSessionId: string) =>
api.get<{ session_id: string; nodes: LogNodeDetail[] }>(
`/agents/${agentId}/logs?session_id=${sessionId}&level=details`,
`/sessions/${sessionId}/logs?session_id=${workerSessionId}&level=details`,
),
tools: (agentId: string, sessionId: string) =>
tools: (sessionId: string, workerSessionId: string) =>
api.get<{ session_id: string; steps: LogToolStep[] }>(
`/agents/${agentId}/logs?session_id=${sessionId}&level=tools`,
`/sessions/${sessionId}/logs?session_id=${workerSessionId}&level=tools`,
),
nodeLogs: (
agentId: string,
sessionId: string,
graphId: string,
nodeId: string,
sessionId: string,
workerSessionId: string,
level?: string,
) =>
api.get<{
@@ -35,6 +35,6 @@ export const logsApi = {
details?: LogNodeDetail[];
tool_logs?: LogToolStep[];
}>(
`/agents/${agentId}/graphs/${graphId}/nodes/${nodeId}/logs?session_id=${sessionId}${level ? `&level=${level}` : ""}`,
`/sessions/${sessionId}/graphs/${graphId}/nodes/${nodeId}/logs?session_id=${workerSessionId}${level ? `&level=${level}` : ""}`,
),
};
+83 -12
View File
@@ -1,36 +1,107 @@
import { api } from "./client";
import type {
LiveSession,
LiveSessionDetail,
SessionSummary,
SessionDetail,
Checkpoint,
Message,
EntryPoint,
} from "./types";
export const sessionsApi = {
list: (agentId: string) =>
api.get<{ sessions: SessionSummary[] }>(`/agents/${agentId}/sessions`),
// --- Session lifecycle ---
get: (agentId: string, sessionId: string) =>
api.get<SessionDetail>(`/agents/${agentId}/sessions/${sessionId}`),
/** Create a session. If agentPath is provided, loads worker in one step. */
create: (agentPath?: string, agentId?: string, model?: string, initialPrompt?: string) =>
api.post<LiveSession>("/sessions", {
agent_path: agentPath,
agent_id: agentId,
model,
initial_prompt: initialPrompt,
}),
delete: (agentId: string, sessionId: string) =>
api.delete<{ deleted: string }>(`/agents/${agentId}/sessions/${sessionId}`),
/** List all active sessions. */
list: () => api.get<{ sessions: LiveSession[] }>("/sessions"),
checkpoints: (agentId: string, sessionId: string) =>
/** Get session detail (includes entry_points, graphs when worker is loaded). */
get: (sessionId: string) =>
api.get<LiveSessionDetail>(`/sessions/${sessionId}`),
/** Stop a session entirely. */
stop: (sessionId: string) =>
api.delete<{ session_id: string; stopped: boolean }>(
`/sessions/${sessionId}`,
),
// --- Worker lifecycle ---
loadWorker: (
sessionId: string,
agentPath: string,
workerId?: string,
model?: string,
) =>
api.post<LiveSession>(`/sessions/${sessionId}/worker`, {
agent_path: agentPath,
worker_id: workerId,
model,
}),
unloadWorker: (sessionId: string) =>
api.delete<{ session_id: string; worker_unloaded: boolean }>(
`/sessions/${sessionId}/worker`,
),
// --- Session info ---
stats: (sessionId: string) =>
api.get<Record<string, unknown>>(`/sessions/${sessionId}/stats`),
entryPoints: (sessionId: string) =>
api.get<{ entry_points: EntryPoint[] }>(
`/sessions/${sessionId}/entry-points`,
),
graphs: (sessionId: string) =>
api.get<{ graphs: string[] }>(`/sessions/${sessionId}/graphs`),
/** Get queen conversation history for a session. */
queenMessages: (sessionId: string) =>
api.get<{ messages: Message[] }>(`/sessions/${sessionId}/queen-messages`),
// --- Worker session browsing (persisted execution runs) ---
workerSessions: (sessionId: string) =>
api.get<{ sessions: SessionSummary[] }>(
`/sessions/${sessionId}/worker-sessions`,
),
workerSession: (sessionId: string, wsId: string) =>
api.get<SessionDetail>(
`/sessions/${sessionId}/worker-sessions/${wsId}`,
),
deleteWorkerSession: (sessionId: string, wsId: string) =>
api.delete<{ deleted: string }>(
`/sessions/${sessionId}/worker-sessions/${wsId}`,
),
checkpoints: (sessionId: string, wsId: string) =>
api.get<{ checkpoints: Checkpoint[] }>(
`/agents/${agentId}/sessions/${sessionId}/checkpoints`,
`/sessions/${sessionId}/worker-sessions/${wsId}/checkpoints`,
),
restore: (agentId: string, sessionId: string, checkpointId: string) =>
restore: (sessionId: string, wsId: string, checkpointId: string) =>
api.post<{ execution_id: string }>(
`/agents/${agentId}/sessions/${sessionId}/checkpoints/${checkpointId}/restore`,
`/sessions/${sessionId}/worker-sessions/${wsId}/checkpoints/${checkpointId}/restore`,
),
messages: (agentId: string, sessionId: string, nodeId?: string) => {
messages: (sessionId: string, wsId: string, nodeId?: string) => {
const params = new URLSearchParams({ client_only: "true" });
if (nodeId) params.set("node_id", nodeId);
return api.get<{ messages: Message[] }>(
`/agents/${agentId}/sessions/${sessionId}/messages?${params}`,
`/sessions/${sessionId}/worker-sessions/${wsId}/messages?${params}`,
);
},
};
+17 -11
View File
@@ -1,15 +1,24 @@
// --- Agent types ---
// --- Session types (primary) ---
export interface Agent {
id: string;
export interface LiveSession {
session_id: string;
worker_id: string | null;
worker_name: string | null;
has_worker: boolean;
agent_path: string;
name: string;
description: string;
goal: string;
node_count: number;
loaded_at: number;
uptime_seconds: number;
intro_message?: string;
/** Present in 409 conflict responses when worker is still loading */
loading?: boolean;
}
export interface LiveSessionDetail extends LiveSession {
entry_points?: EntryPoint[];
graphs?: string[];
}
export interface EntryPoint {
@@ -19,11 +28,6 @@ export interface EntryPoint {
trigger_type: string;
}
export interface AgentDetail extends Agent {
entry_points: EntryPoint[];
graphs: string[];
}
export interface DiscoverEntry {
path: string;
name: string;
@@ -35,6 +39,7 @@ export interface DiscoverEntry {
tags: string[];
last_active: string | null;
is_loaded: boolean;
version?: string | null;
}
/** Keyed by category name. */
@@ -51,7 +56,7 @@ export interface InjectResult {
}
export interface ChatResult {
status: "started" | "injected";
status: "started" | "injected" | "queen";
execution_id?: string;
node_id?: string;
delivered?: boolean;
@@ -257,7 +262,8 @@ export type EventTypeName =
| "context_compacted"
| "webhook_received"
| "custom"
| "escalation_requested";
| "escalation_requested"
| "worker_loaded";
export interface AgentEvent {
type: EventTypeName;
+51 -150
View File
@@ -1,6 +1,5 @@
import { useMemo, useState, useRef, useEffect } from "react";
import ReactDOM from "react-dom";
import { Play, Pause, Loader2, CheckCircle2, GitBranch, Zap, Layers } from "lucide-react";
import { memo, useMemo, useState, useRef } from "react";
import { Play, Pause, Loader2, CheckCircle2 } from "lucide-react";
export type NodeStatus = "running" | "complete" | "pending" | "error" | "looping";
@@ -17,19 +16,63 @@ export interface GraphNode {
}
type RunState = "idle" | "deploying" | "running";
type VersionBump = "major" | "minor";
interface AgentGraphProps {
nodes: GraphNode[];
title: string;
onNodeClick?: (node: GraphNode) => void;
onVersionBump?: (type: VersionBump) => void;
onRun?: () => void;
onPause?: () => void;
version?: string;
runState?: RunState;
}
// --- Extracted RunButton so hover state survives parent re-renders ---
interface RunButtonProps {
runState: RunState;
disabled: boolean;
onRun: () => void;
onPause: () => void;
btnRef: React.Ref<HTMLButtonElement>;
}
const RunButton = memo(function RunButton({ runState, disabled, onRun, onPause, btnRef }: RunButtonProps) {
const [hovered, setHovered] = useState(false);
const showPause = runState === "running" && hovered;
return (
<button
ref={btnRef}
onClick={runState === "running" ? onPause : onRun}
disabled={runState === "deploying" || disabled}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all duration-200 ${
showPause
? "bg-amber-500/15 text-amber-400 border border-amber-500/40 hover:bg-amber-500/25 active:scale-95 cursor-pointer"
: runState === "running"
? "bg-green-500/15 text-green-400 border border-green-500/30 cursor-pointer"
: runState === "deploying"
? "bg-primary/10 text-primary border border-primary/20 cursor-default"
: disabled
? "bg-muted/30 text-muted-foreground/40 border border-border/20 cursor-not-allowed"
: "bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 hover:border-primary/40 active:scale-95"
}`}
>
{runState === "deploying" ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : showPause ? (
<Pause className="w-3 h-3 fill-current" />
) : runState === "running" ? (
<CheckCircle2 className="w-3 h-3" />
) : (
<Play className="w-3 h-3 fill-current" />
)}
{runState === "deploying" ? "Deploying\u2026" : showPause ? "Pause" : runState === "running" ? "Running" : "Run"}
</button>
);
});
const NODE_W_MAX = 180;
const NODE_H = 44;
const GAP_Y = 48;
@@ -80,42 +123,16 @@ function formatLabel(id: string): string {
.join(" ");
}
export default function AgentGraph({ nodes, title: _title, onNodeClick, onVersionBump, onRun, onPause, version, runState: externalRunState }: AgentGraphProps) {
export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, onPause, version, runState: externalRunState }: AgentGraphProps) {
const [localRunState, setLocalRunState] = useState<RunState>("idle");
const runState = externalRunState ?? localRunState;
const [versionPopover, setVersionPopover] = useState<"hidden" | "confirm" | "pick">("hidden");
const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null);
const runBtnRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
// Close popover on outside click
useEffect(() => {
if (versionPopover === "hidden") return;
const handler = (e: MouseEvent) => {
if (
popoverRef.current && !popoverRef.current.contains(e.target as Node) &&
runBtnRef.current && !runBtnRef.current.contains(e.target as Node)
) setVersionPopover("hidden");
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [versionPopover]);
const handleRun = () => {
if (runState !== "idle") return;
if (runBtnRef.current) {
const rect = runBtnRef.current.getBoundingClientRect();
setPopoverPos({ top: rect.bottom + 6, left: rect.right });
}
setVersionPopover("confirm");
};
const startRun = () => {
setVersionPopover("hidden");
if (onRun) {
onRun();
} else {
// Fallback: local state animation when no backend callback provided
setLocalRunState("deploying");
setTimeout(() => setLocalRunState("running"), 1800);
setTimeout(() => setLocalRunState("idle"), 5000);
@@ -226,120 +243,6 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onVersio
return { layers, cols, maxCols, nodeW, colSpacing, firstColX };
}, [nodes, forwardEdges]);
const RunButton = () => {
const [hovered, setHovered] = useState(false);
const showPause = runState === "running" && hovered;
return (
<button
ref={runBtnRef}
onClick={runState === "running" ? onPause : handleRun}
disabled={runState === "deploying" || nodes.length === 0}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all duration-200 ${
showPause
? "bg-amber-500/15 text-amber-400 border border-amber-500/40 hover:bg-amber-500/25 active:scale-95 cursor-pointer"
: runState === "running"
? "bg-green-500/15 text-green-400 border border-green-500/30 cursor-pointer"
: runState === "deploying"
? "bg-primary/10 text-primary border border-primary/20 cursor-default"
: nodes.length === 0
? "bg-muted/30 text-muted-foreground/40 border border-border/20 cursor-not-allowed"
: "bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 hover:border-primary/40 active:scale-95"
}`}
>
{runState === "deploying" ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : showPause ? (
<Pause className="w-3 h-3 fill-current" />
) : runState === "running" ? (
<CheckCircle2 className="w-3 h-3" />
) : (
<Play className="w-3 h-3 fill-current" />
)}
{runState === "deploying" ? "Deploying\u2026" : showPause ? "Pause" : runState === "running" ? "Running" : "Run"}
</button>
);
};
// Version bump popover (portalled)
const VersionPopover = () => {
if (versionPopover === "hidden" || !popoverPos) return null;
return ReactDOM.createPortal(
<div
ref={popoverRef}
style={{ position: "fixed", top: popoverPos.top, right: window.innerWidth - popoverPos.left, zIndex: 9999 }}
className="w-64 rounded-xl border border-border/60 bg-card shadow-xl shadow-black/30 overflow-hidden"
>
{versionPopover === "confirm" && (
<>
<div className="px-3.5 py-3 border-b border-border/40">
<div className="flex items-center gap-2 mb-1">
<GitBranch className="w-3.5 h-3.5 text-primary" />
<span className="text-xs font-semibold text-foreground">Changes Detected</span>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
The pipeline has been modified since the last run. Would you like to save this as a new version?
</p>
</div>
<div className="p-1.5 flex flex-col gap-0.5">
<button
onClick={() => setVersionPopover("pick")}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm text-left transition-colors hover:bg-muted/60 text-foreground font-medium"
>
Yes, create new version
</button>
<button
onClick={startRun}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm text-left transition-colors hover:bg-muted/60 text-muted-foreground"
>
No, run as-is
</button>
</div>
</>
)}
{versionPopover === "pick" && (
<>
<div className="px-3.5 py-3 border-b border-border/40">
<span className="text-xs font-semibold text-foreground">Select Change Type</span>
{version && (
<p className="text-[11px] text-muted-foreground mt-0.5">Current version: <span className="text-foreground font-medium">{version}</span></p>
)}
</div>
<div className="p-1.5 flex flex-col gap-0.5">
<button
onClick={() => { onVersionBump?.("major"); startRun(); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-left transition-colors hover:bg-muted/60"
>
<div className="w-7 h-7 rounded-md bg-primary/10 flex items-center justify-center flex-shrink-0">
<Zap className="w-3.5 h-3.5 text-primary" />
</div>
<div>
<div className="text-sm font-medium text-foreground">Major change</div>
<div className="text-[11px] text-muted-foreground">Resets minor (e.g. v2.3 &rarr; v3.0)</div>
</div>
</button>
<button
onClick={() => { onVersionBump?.("minor"); startRun(); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-left transition-colors hover:bg-muted/60"
>
<div className="w-7 h-7 rounded-md bg-muted/60 flex items-center justify-center flex-shrink-0">
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
</div>
<div>
<div className="text-sm font-medium text-foreground">Minor change</div>
<div className="text-[11px] text-muted-foreground">Increments patch (e.g. v2.3 &rarr; v2.4)</div>
</div>
</button>
</div>
</>
)}
</div>,
document.body
);
};
if (nodes.length === 0) {
return (
<div className="flex flex-col h-full">
@@ -352,9 +255,8 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onVersio
</span>
)}
</div>
<RunButton />
<RunButton runState={runState} disabled={nodes.length === 0} onRun={handleRun} onPause={onPause ?? (() => {})} btnRef={runBtnRef} />
</div>
<VersionPopover />
<div className="flex-1 flex items-center justify-center px-5">
<p className="text-xs text-muted-foreground/60 text-center italic">No pipeline configured yet.<br/>Chat with the Queen to get started.</p>
</div>
@@ -594,9 +496,8 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onVersio
</span>
)}
</div>
<RunButton />
<RunButton runState={runState} disabled={nodes.length === 0} onRun={handleRun} onPause={onPause ?? (() => {})} btnRef={runBtnRef} />
</div>
<VersionPopover />
{/* Graph */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 pb-5">
+2 -1
View File
@@ -108,6 +108,7 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
if (m.type === "system" && !m.thread) return false;
return m.thread === activeThread;
});
console.log('[ChatPanel] render: messages:', messages.length, 'threadMessages:', threadMessages.length, 'activeThread:', activeThread, 'threads:', [...new Set(messages.map(m => m.thread))]);
// Mark current thread as read
useEffect(() => {
@@ -140,7 +141,7 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
</div>
{/* Messages */}
<div className="flex-1 overflow-auto scrollbar-hide px-5 py-4 space-y-3">
<div className="flex-1 overflow-auto px-5 py-4 space-y-3">
{threadMessages.map((msg) => (
<MessageBubble key={msg.id} msg={msg} />
))}
+157 -36
View File
@@ -38,6 +38,23 @@ interface CredentialRow {
required: boolean;
credentialKey: string; // key name within the credential (e.g., "api_key")
adenSupported: boolean; // whether this credential uses OAuth via Aden
valid: boolean | null; // true = health check passed, false = failed, null = not checked
validationMessage: string | null;
}
function requirementToRow(r: AgentCredentialRequirement): CredentialRow {
return {
id: r.credential_id,
name: r.credential_name,
description: r.description,
icon: "\uD83D\uDD11",
connected: r.available,
required: true,
credentialKey: r.credential_key || "api_key",
adenSupported: r.aden_supported,
valid: r.valid,
validationMessage: r.validation_message,
};
}
// Module-level cache: credential requirements are static per agent path.
@@ -72,6 +89,10 @@ export default function CredentialsModal({
const [editingId, setEditingId] = useState<string | null>(null);
const [inputValue, setInputValue] = useState("");
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [hasAdenKey, setHasAdenKey] = useState(true); // assume true until backend says otherwise
const [adenKeyInput, setAdenKeyInput] = useState("");
const [savingAdenKey, setSavingAdenKey] = useState(false);
const fetchStatus = useCallback(async () => {
setError(null);
@@ -80,35 +101,17 @@ export default function CredentialsModal({
// Check cache first — credential requirements are static per agent
const cached = credentialCache.get(agentPath);
if (cached) {
setRows(cached.map((r: AgentCredentialRequirement) => ({
id: r.credential_id,
name: r.credential_name,
description: r.description,
icon: "\uD83D\uDD11",
connected: r.available,
required: true,
credentialKey: r.credential_key || "api_key",
adenSupported: r.aden_supported,
})));
setRows(cached.map(requirementToRow));
setLoading(false);
return;
}
// Real agent — ask backend what credentials it actually needs
setLoading(true);
const { required } = await credentialsApi.checkAgent(agentPath);
const { required, has_aden_key } = await credentialsApi.checkAgent(agentPath);
setHasAdenKey(has_aden_key);
credentialCache.set(agentPath, required);
const newRows: CredentialRow[] = required.map((r: AgentCredentialRequirement) => ({
id: r.credential_id,
name: r.credential_name,
description: r.description,
icon: "\uD83D\uDD11",
connected: r.available,
required: true,
credentialKey: r.credential_key || "api_key",
adenSupported: r.aden_supported,
}));
setRows(newRows);
setRows(required.map(requirementToRow));
} else {
// No real path — no credentials to show
setRows([]);
@@ -120,6 +123,8 @@ export default function CredentialsModal({
...c,
credentialKey: "api_key",
adenSupported: false,
valid: null,
validationMessage: null,
})));
} else {
setRows([]);
@@ -135,9 +140,27 @@ export default function CredentialsModal({
fetchStatus();
setEditingId(null);
setInputValue("");
setAdenKeyInput("");
setDeletingId(null);
}
}, [open, fetchStatus]);
const handleSaveAdenKey = async () => {
if (!adenKeyInput.trim()) return;
setSavingAdenKey(true);
try {
await credentialsApi.saveAdenKey(adenKeyInput.trim());
setAdenKeyInput("");
if (agentPath) credentialCache.delete(agentPath);
onCredentialChange?.();
await fetchStatus();
} catch {
setError("Failed to save Aden API Key");
} finally {
setSavingAdenKey(false);
}
};
const handleConnect = async (row: CredentialRow) => {
if (row.adenSupported) {
// OAuth credential — redirect to Aden platform
@@ -165,6 +188,7 @@ export default function CredentialsModal({
// Start editing — show inline API key input
setEditingId(row.id);
setInputValue("");
setDeletingId(null);
}
};
@@ -188,7 +212,10 @@ export default function CredentialsModal({
const connectedCount = rows.filter(c => c.connected).length;
const requiredCount = rows.filter(c => c.required).length;
const requiredConnected = rows.filter(c => c.required && c.connected).length;
const allRequiredMet = requiredConnected === requiredCount;
const invalidCount = rows.filter(c => c.valid === false).length;
const missingCount = requiredCount - requiredConnected;
const allRequiredMet = requiredConnected === requiredCount && invalidCount === 0;
const needsAdenKeyInput = !hasAdenKey && rows.some(r => r.adenSupported);
return (
<>
@@ -224,12 +251,16 @@ export default function CredentialsModal({
{allRequiredMet ? (
<>
<Shield className="w-3.5 h-3.5" />
All required credentials connected ({connectedCount}/{rows.length} total)
{rows.length === 0
? "No required credentials!"
: `All required credentials connected (${connectedCount}/${rows.length} total)`}
</>
) : (
<>
<AlertCircle className="w-3.5 h-3.5" />
{requiredCount - requiredConnected} required credential{requiredCount - requiredConnected !== 1 ? "s" : ""} missing
{missingCount > 0 && `${missingCount} missing`}
{missingCount > 0 && invalidCount > 0 && ", "}
{invalidCount > 0 && `${invalidCount} invalid`}
</>
)}
</div>
@@ -249,6 +280,50 @@ export default function CredentialsModal({
</div>
)}
{/* Aden API Key section */}
{!loading && needsAdenKeyInput && (
<div className="mx-5 mt-4 px-3 py-3 rounded-lg border border-amber-500/30 bg-amber-500/5">
<div className="flex items-center gap-2 mb-1">
<KeyRound className="w-3.5 h-3.5 text-amber-600" />
<span className="text-sm font-medium text-foreground">Aden API Key</span>
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded text-destructive/70 bg-destructive/10">
Required
</span>
</div>
<p className="text-[11px] text-muted-foreground mb-2">
Required to connect OAuth integrations below.{" "}
<a
href="https://hive.adenhq.com/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-0.5"
>
Get your key at hive.adenhq.com
<ExternalLink className="w-2.5 h-2.5" />
</a>
</p>
<div className="flex gap-2">
<input
type="password"
value={adenKeyInput}
onChange={(e) => setAdenKeyInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveAdenKey();
}}
placeholder="Paste your ADEN_API_KEY..."
className="flex-1 px-3 py-1.5 rounded-md border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<button
onClick={handleSaveAdenKey}
disabled={savingAdenKey || !adenKeyInput.trim()}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{savingAdenKey ? <Loader2 className="w-3 h-3 animate-spin" /> : "Save"}
</button>
</div>
</div>
)}
{/* Credential list */}
{!loading && (
<div className="p-5 space-y-2">
@@ -256,9 +331,11 @@ export default function CredentialsModal({
<div key={row.id}>
<div
className={`flex items-center gap-3 px-3 py-3 rounded-lg border transition-colors ${
row.connected
row.connected && row.valid !== false
? "border-primary/20 bg-primary/[0.03]"
: "border-border/60 bg-muted/20"
: row.valid === false
? "border-destructive/30 bg-destructive/[0.03]"
: "border-border/60 bg-muted/20"
}`}
>
<span className="text-lg flex-shrink-0">{row.icon}</span>
@@ -276,18 +353,36 @@ export default function CredentialsModal({
)}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">{row.description}</p>
{row.valid === false && row.validationMessage && (
<p className="text-[11px] text-destructive mt-0.5">{row.validationMessage}</p>
)}
</div>
{row.connected ? (
<div className="flex items-center gap-1 flex-shrink-0">
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-primary/10 text-primary">
<Check className="w-3 h-3" />
Connected
</span>
{row.valid === false ? (
<button
onClick={() => handleConnect(row)}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-destructive/10 text-destructive hover:bg-destructive/15 transition-colors"
title={row.validationMessage || "Invalid — click to update"}
>
<AlertCircle className="w-3 h-3" />
{row.adenSupported ? "Reauthorize" : "Update Key"}
</button>
) : (
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-primary/10 text-primary">
<Check className="w-3 h-3" />
Connected
</span>
)}
<button
onClick={() => handleDisconnect(row)}
onClick={() => {
setDeletingId(deletingId === row.id ? null : row.id);
if (editingId) { setEditingId(null); setInputValue(""); }
}}
disabled={saving}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Disconnect"
title="Delete credential"
>
<Trash2 className="w-3 h-3" />
</button>
@@ -313,8 +408,34 @@ export default function CredentialsModal({
)}
</div>
{/* Inline delete confirmation */}
{deletingId === row.id && (
<div className="mt-1.5 flex items-center gap-2 px-3 py-2 rounded-lg border border-destructive/30 bg-destructive/5">
<AlertCircle className="w-3.5 h-3.5 text-destructive flex-shrink-0" />
<span className="text-xs text-destructive flex-1">
Permanently delete this API key?
</span>
<button
onClick={() => {
setDeletingId(null);
handleDisconnect(row);
}}
disabled={saving}
className="px-3 py-1 rounded-md text-xs font-medium bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : "Delete"}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-2 py-1 rounded-md text-xs text-muted-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
</div>
)}
{/* Inline API key input */}
{editingId === row.id && !row.connected && (
{editingId === row.id && (!row.connected || row.valid === false) && (
<div className="mt-1.5 flex gap-2 px-3">
<input
type="password"
@@ -324,7 +445,7 @@ export default function CredentialsModal({
if (e.key === "Enter") handleConnect(row);
if (e.key === "Escape") { setEditingId(null); setInputValue(""); }
}}
placeholder={`Paste your ${row.name} API key...`}
placeholder={`${row.connected ? "Enter new" : "Paste your"} ${row.name} API key...`}
autoFocus
className="flex-1 px-3 py-1.5 rounded-md border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
@@ -360,7 +481,7 @@ export default function CredentialsModal({
: "bg-muted text-muted-foreground cursor-not-allowed"
}`}
>
{allRequiredMet ? "Done" : "Connect required credentials to continue"}
{allRequiredMet ? "Done" : missingCount > 0 ? "Connect required credentials to continue" : "Fix invalid credentials to continue"}
</button>
</div>
)}
@@ -23,9 +23,9 @@ interface ToolCredential {
interface NodeDetailPanelProps {
node: GraphNode | null;
nodeSpec?: NodeSpec | null;
agentId?: string;
sessionId?: string;
graphId?: string;
sessionId?: string | null;
workerSessionId?: string | null;
nodeLogs?: string[];
actionPlan?: string;
onClose: () => void;
@@ -97,14 +97,14 @@ function ToolRow({ tool }: { tool: Tool }) {
);
}
function LogsTab({ nodeId, isActive: _isActive, agentId, graphId, sessionId, nodeLogs }: { nodeId: string; isActive: boolean; agentId?: string; graphId?: string; sessionId?: string | null; nodeLogs?: string[] }) {
function LogsTab({ nodeId, isActive: _isActive, sessionId, graphId, workerSessionId, nodeLogs }: { nodeId: string; isActive: boolean; sessionId?: string; graphId?: string; workerSessionId?: string | null; nodeLogs?: string[] }) {
const [historicalLines, setHistoricalLines] = useState<string[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
// Fetch historical logs when session is available (post-execution viewing)
useEffect(() => {
if (agentId && graphId && sessionId) {
logsApi.nodeLogs(agentId, graphId, nodeId, sessionId)
if (sessionId && graphId && workerSessionId) {
logsApi.nodeLogs(sessionId, graphId, nodeId, workerSessionId)
.then(r => {
const realLines: string[] = [];
if (r.details) {
@@ -123,7 +123,7 @@ function LogsTab({ nodeId, isActive: _isActive, agentId, graphId, sessionId, nod
})
.catch(() => { /* keep fallback on error */ });
}
}, [agentId, graphId, nodeId, sessionId]);
}, [sessionId, graphId, nodeId, workerSessionId]);
// Resolve which lines to display: live SSE logs > historical > default
const lines = (nodeLogs && nodeLogs.length > 0)
@@ -213,7 +213,7 @@ const tabs: { id: Tab; label: string; Icon: React.FC<{ className?: string }> }[]
{ id: "subagents", label: "Subagents", Icon: ({ className }) => <Bot className={className} /> },
];
export default function NodeDetailPanel({ node, nodeSpec, agentId, graphId, sessionId, nodeLogs, actionPlan, onClose }: NodeDetailPanelProps) {
export default function NodeDetailPanel({ node, nodeSpec, sessionId, graphId, workerSessionId, nodeLogs, actionPlan, onClose }: NodeDetailPanelProps) {
const [activeTab, setActiveTab] = useState<Tab>("overview");
const [realTools, setRealTools] = useState<ToolInfo[] | null>(null);
const [realCriteria, setRealCriteria] = useState<NodeCriteria | null>(null);
@@ -224,23 +224,23 @@ export default function NodeDetailPanel({ node, nodeSpec, agentId, graphId, sess
setRealCriteria(null);
}, [node?.id]);
// Fetch real tool descriptions when Tools tab is active and agent is loaded
// Fetch real tool descriptions when Tools tab is active and session is loaded
useEffect(() => {
if (activeTab === "tools" && agentId && graphId && node) {
graphsApi.nodeTools(agentId, graphId, node.id)
if (activeTab === "tools" && sessionId && graphId && node) {
graphsApi.nodeTools(sessionId, graphId, node.id)
.then(r => setRealTools(r.tools))
.catch(() => setRealTools(null));
}
}, [activeTab, agentId, graphId, node?.id]);
}, [activeTab, sessionId, graphId, node?.id]);
// Fetch real criteria when Overview tab is active and agent is loaded
// Fetch real criteria when Overview tab is active and session is loaded
useEffect(() => {
if (activeTab === "overview" && agentId && graphId && node) {
graphsApi.nodeCriteria(agentId, graphId, node.id, sessionId || undefined)
if (activeTab === "overview" && sessionId && graphId && node) {
graphsApi.nodeCriteria(sessionId, graphId, node.id, workerSessionId || undefined)
.then(r => setRealCriteria(r))
.catch(() => setRealCriteria(null));
}
}, [activeTab, agentId, graphId, node?.id, sessionId]);
}, [activeTab, sessionId, graphId, node?.id, workerSessionId]);
if (!node) return null;
@@ -390,7 +390,7 @@ export default function NodeDetailPanel({ node, nodeSpec, agentId, graphId, sess
)}
{activeTab === "logs" && (
<LogsTab nodeId={node.id} isActive={isActive} agentId={agentId} graphId={graphId} sessionId={sessionId} nodeLogs={nodeLogs} />
<LogsTab nodeId={node.id} isActive={isActive} sessionId={sessionId} graphId={graphId} workerSessionId={workerSessionId} nodeLogs={nodeLogs} />
)}
{activeTab === "prompt" && (
+161
View File
@@ -0,0 +1,161 @@
import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Crown, X } from "lucide-react";
import { loadPersistedTabs, savePersistedTabs, TAB_STORAGE_KEY, type PersistedTabState } from "@/lib/tab-persistence";
import { sessionsApi } from "@/api/sessions";
export interface TopBarTab {
agentType: string;
label: string;
isActive: boolean;
hasRunning: boolean;
}
interface TopBarProps {
/** Live tabs from workspace state. When omitted, reads from localStorage. */
tabs?: TopBarTab[];
/** Called when a tab is clicked (workspace overrides to setActiveWorker). */
onTabClick?: (agentType: string) => void;
/** Called when a tab's X is clicked (workspace overrides for SSE teardown). */
onCloseTab?: (agentType: string) => void;
/** Whether close buttons are shown. Defaults to true when >1 tab. */
canCloseTabs?: boolean;
/** Content rendered right after the tab strip (e.g. + button). */
afterTabs?: React.ReactNode;
/** Right-side slot for page-specific controls (e.g. credentials). */
children?: React.ReactNode;
}
export default function TopBar({ tabs: tabsProp, onTabClick, onCloseTab, canCloseTabs, afterTabs, children }: TopBarProps) {
const navigate = useNavigate();
// Fallback: read persisted tabs when no live tabs provided
const [persisted, setPersisted] = useState<PersistedTabState | null>(() =>
tabsProp ? null : loadPersistedTabs()
);
const tabs: TopBarTab[] = tabsProp ?? deriveTabs(persisted);
const showClose = canCloseTabs ?? true;
const handleTabClick = useCallback((agentType: string) => {
if (onTabClick) {
onTabClick(agentType);
} else {
navigate(`/workspace?agent=${encodeURIComponent(agentType)}`);
}
}, [onTabClick, navigate]);
const handleCloseTab = useCallback((agentType: string, e: React.MouseEvent) => {
e.stopPropagation();
if (onCloseTab) {
onCloseTab(agentType);
return;
}
// Kill the backend session (queen/judge/worker) even outside workspace
sessionsApi.list()
.then(({ sessions }) => {
const match = sessions.find(s => s.agent_path === agentType);
if (match) return sessionsApi.stop(match.session_id);
})
.catch(() => {}); // fire-and-forget
// Fallback: update localStorage directly (non-workspace pages)
setPersisted(prev => {
if (!prev) return null;
const nextTabs = prev.tabs.filter(t => t.agentType !== agentType);
if (nextTabs.length === 0) {
localStorage.removeItem(TAB_STORAGE_KEY);
return null;
}
const removedIds = new Set(prev.tabs.filter(t => t.agentType === agentType).map(t => t.id));
const nextSessions = { ...prev.sessions };
for (const id of removedIds) delete nextSessions[id];
const nextActiveSession = { ...prev.activeSessionByAgent };
delete nextActiveSession[agentType];
const nextActiveWorker = prev.activeWorker === agentType
? nextTabs[0].agentType
: prev.activeWorker;
const nextState: PersistedTabState = {
tabs: nextTabs,
activeSessionByAgent: nextActiveSession,
activeWorker: nextActiveWorker,
sessions: nextSessions,
};
savePersistedTabs(nextState);
return nextState;
});
}, [onCloseTab]);
return (
<div className="relative h-12 flex items-center justify-between px-5 border-b border-border/60 bg-card/50 backdrop-blur-sm flex-shrink-0">
<div className="flex items-center gap-3 min-w-0">
<button onClick={() => navigate("/")} className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-shrink-0">
<Crown className="w-4 h-4 text-primary" />
<span className="text-sm font-semibold text-primary">Hive</span>
</button>
{tabs.length > 0 && (
<>
<span className="text-border text-xs flex-shrink-0">|</span>
<div className="flex items-center gap-0.5 min-w-0 overflow-x-auto scrollbar-hide">
{tabs.map((tab) => (
<button
key={tab.agentType}
onClick={() => handleTabClick(tab.agentType)}
className={`group flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors whitespace-nowrap flex-shrink-0 ${
tab.isActive
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
{tab.hasRunning && (
<span className="relative flex h-1.5 w-1.5 flex-shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-60" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-primary" />
</span>
)}
<span>{tab.label}</span>
{showClose && (
<X
className="w-3 h-3 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity"
onClick={(e) => handleCloseTab(tab.agentType, e)}
/>
)}
</button>
))}
</div>
{afterTabs}
</>
)}
</div>
{children && (
<div className="flex items-center gap-1 flex-shrink-0">
{children}
</div>
)}
</div>
);
}
/** Derive TopBarTab[] from persisted localStorage state (used outside workspace). */
function deriveTabs(persisted: PersistedTabState | null): TopBarTab[] {
if (!persisted) return [];
const seen = new Set<string>();
const tabs: TopBarTab[] = [];
for (const tab of persisted.tabs) {
if (seen.has(tab.agentType)) continue;
seen.add(tab.agentType);
const sessionData = persisted.sessions?.[tab.id];
const hasRunning = sessionData?.graphNodes?.some(
(n) => n.status === "running" || n.status === "looping"
) ?? false;
tabs.push({
agentType: tab.agentType,
label: tab.label,
isActive: false, // no active tab outside workspace
hasRunning,
});
}
return tabs;
}
+66 -6
View File
@@ -2,14 +2,14 @@ import { useEffect, useRef, useCallback, useState } from "react";
import type { AgentEvent, EventTypeName } from "@/api/types";
interface UseSSEOptions {
agentId: string;
sessionId: string;
eventTypes?: EventTypeName[];
onEvent?: (event: AgentEvent) => void;
enabled?: boolean;
}
export function useSSE({
agentId,
sessionId,
eventTypes,
onEvent,
enabled = true,
@@ -23,9 +23,9 @@ export function useSSE({
const typesKey = eventTypes?.join(",") ?? "";
useEffect(() => {
if (!enabled || !agentId) return;
if (!enabled || !sessionId) return;
let url = `/api/agents/${agentId}/events`;
let url = `/api/sessions/${sessionId}/events`;
if (eventTypes?.length) {
url += `?types=${eventTypes.join(",")}`;
}
@@ -46,7 +46,6 @@ export function useSSE({
}
};
// Listen on generic message for all events
es.onmessage = handler;
return () => {
@@ -54,7 +53,7 @@ export function useSSE({
eventSourceRef.current = null;
setConnected(false);
};
}, [agentId, enabled, typesKey]);
}, [sessionId, enabled, typesKey]);
const close = useCallback(() => {
eventSourceRef.current?.close();
@@ -64,3 +63,64 @@ export function useSSE({
return { connected, lastEvent, close };
}
// --- Multi-session SSE hook ---
interface UseMultiSSEOptions {
/** Map of agentType → backendSessionId. Only non-empty IDs get an EventSource. */
sessions: Record<string, string>;
onEvent: (agentType: string, event: AgentEvent) => void;
}
/**
* Manages one EventSource per loaded session. Diffs `sessions` on each render:
* opens new connections, closes removed ones, leaves existing ones alone.
*/
export function useMultiSSE({ sessions, onEvent }: UseMultiSSEOptions) {
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const sourcesRef = useRef(new Map<string, EventSource>());
// Diff-based open/close — runs on every `sessions` change
useEffect(() => {
const current = sourcesRef.current;
const desired = new Set(Object.keys(sessions));
// Close connections for sessions no longer in the map
for (const [agentType, es] of current) {
if (!desired.has(agentType)) {
es.close();
current.delete(agentType);
}
}
// Open connections for newly added sessions
for (const [agentType, sessionId] of Object.entries(sessions)) {
if (!sessionId || current.has(agentType)) continue;
const url = `/api/sessions/${sessionId}/events`;
const es = new EventSource(url);
es.onmessage = (e: MessageEvent) => {
try {
const event: AgentEvent = JSON.parse(e.data);
console.log('[SSE] received:', agentType, event.type, event.stream_id, event.node_id);
onEventRef.current(agentType, event);
} catch {
// Ignore parse errors (keepalive comments)
}
};
current.set(agentType, es);
}
}, [sessions]);
// Close all on unmount only
useEffect(() => {
return () => {
for (const es of sourcesRef.current.values()) es.close();
sourcesRef.current.clear();
};
}, []);
}
+50 -1
View File
@@ -89,7 +89,56 @@
}
}
/* Scrollbar hide utility */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover,
*:active {
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
/* Webkit (Chrome/Safari/Edge) — thin overlay track */
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
}
*:hover::-webkit-scrollbar-thumb,
*:active::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Light mode adjustments */
:root:not(.dark) *:hover,
:root:not(.dark) *:active {
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
:root:not(.dark) *:hover::-webkit-scrollbar-thumb,
:root:not(.dark) *:active::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
}
:root:not(.dark) *::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.35);
}
/* Keep scrollbar-hide for elements that truly need no scrollbar (e.g. tab bars) */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
+12
View File
@@ -110,6 +110,18 @@ export function sseEventToChatMessage(
};
}
case "execution_paused": {
return {
id: `paused-${event.execution_id}`,
agent: "System",
agentColor: "",
content: "Execution paused by user",
timestamp: "",
type: "system",
thread,
};
}
case "execution_failed": {
const error = (event.data?.error as string) || "Execution failed";
return {
+49
View File
@@ -0,0 +1,49 @@
/**
* Shared tab persistence utilities for workspace sessions.
* Used by both TopBar and workspace.tsx.
*/
import type { ChatMessage } from "@/components/ChatPanel";
import type { GraphNode } from "@/components/AgentGraph";
export const TAB_STORAGE_KEY = "hive:workspace-tabs";
export interface PersistedTabState {
tabs: Array<{ id: string; agentType: string; label: string }>;
activeSessionByAgent: Record<string, string>;
activeWorker: string;
sessions?: Record<string, { messages: ChatMessage[]; graphNodes: GraphNode[] }>;
}
export function loadPersistedTabs(): PersistedTabState | null {
try {
const raw = localStorage.getItem(TAB_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed.tabs) || parsed.tabs.length === 0) return null;
return parsed as PersistedTabState;
} catch {
return null;
}
}
const MAX_PERSISTED_MESSAGES = 50;
export function savePersistedTabs(state: PersistedTabState): void {
try {
const capped = { ...state };
if (capped.sessions) {
const trimmed: typeof capped.sessions = {};
for (const [id, data] of Object.entries(capped.sessions)) {
trimmed[id] = {
messages: data.messages.slice(-MAX_PERSISTED_MESSAGES),
graphNodes: data.graphNodes,
};
}
capped.sessions = trimmed;
}
localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(capped));
} catch {
// localStorage full or unavailable — silently ignore
}
}
+3 -6
View File
@@ -1,13 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
);
+2 -7
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Crown, Mail, Briefcase, Shield, Search, Newspaper, ArrowRight, Hexagon, Send, Bot } from "lucide-react";
import TopBar from "@/components/TopBar";
import type { LucideIcon } from "lucide-react";
import { agentsApi } from "@/api/agents";
import type { DiscoverEntry } from "@/api/types";
@@ -78,13 +79,7 @@ export default function Home() {
return (
<div className="min-h-screen bg-background flex flex-col">
{/* Top bar */}
<div className="h-12 flex items-center px-6 border-b border-border/40 flex-shrink-0">
<div className="flex items-center gap-2">
<Crown className="w-4 h-4 text-primary" />
<span className="text-sm font-semibold text-primary">Hive</span>
</div>
</div>
<TopBar />
{/* Main content */}
<div className="flex-1 flex flex-col items-center justify-center p-6">
+3 -8
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Crown, Bot, Activity, Moon, Plus } from "lucide-react";
import { Bot, Activity, Moon, Plus } from "lucide-react";
import TopBar from "@/components/TopBar";
import { agentsApi } from "@/api/agents";
import type { DiscoverEntry } from "@/api/types";
@@ -41,13 +42,7 @@ export default function MyAgents() {
return (
<div className="min-h-screen bg-background flex flex-col">
{/* Top bar */}
<div className="h-12 flex items-center px-6 border-b border-border/40 flex-shrink-0">
<button onClick={() => navigate("/")} className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<Crown className="w-4 h-4 text-primary" />
<span className="text-sm font-semibold text-primary">Hive</span>
</button>
</div>
<TopBar />
{/* Content */}
<div className="flex-1 p-6 md:p-10 max-w-5xl mx-auto w-full">
File diff suppressed because it is too large Load Diff
+68 -19
View File
@@ -614,17 +614,45 @@ $imports = @(
@{ Module = "framework.mcp.agent_builder_server"; Label = "MCP server module"; Required = $true }
)
foreach ($imp in $imports) {
Write-Host " $($imp.Label)... " -NoNewline
$null = & uv run python -c "import $($imp.Module)" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Ok "ok"
} elseif ($imp.Required) {
Write-Fail "failed"
$importErrors++
} else {
Write-Warn "issues (may be OK)"
# Batch check all imports in single process (reduces subprocess spawning overhead)
$modulesToCheck = @("framework", "aden_tools", "litellm", "framework.mcp.agent_builder_server")
try {
$checkOutput = & uv run $PythonCmd scripts/check_requirements.py @modulesToCheck 2>&1 | Out-String
$resultJson = $null
# Try to parse JSON result
try {
$resultJson = $checkOutput | ConvertFrom-Json
} catch {
Write-Fail "Failed to parse import check results"
Write-Host $checkOutput
exit 1
}
# Display results for each module
foreach ($imp in $imports) {
Write-Host " $($imp.Label)... " -NoNewline
$status = $resultJson.$($imp.Module)
if ($status -eq "ok") {
Write-Ok "ok"
} elseif ($imp.Required) {
Write-Fail "failed"
if ($status) {
Write-Host " $status" -ForegroundColor Red
}
$importErrors++
} else {
Write-Warn "issues (may be OK)"
if ($status -and $status -ne "ok") {
Write-Host " $status" -ForegroundColor Yellow
}
}
}
} catch {
Write-Fail "Import check failed: $($_.Exception.Message)"
exit 1
}
if ($importErrors -gt 0) {
@@ -975,16 +1003,37 @@ Write-Step -Number "7" -Text "Step 7: Verifying installation..."
$verifyErrors = 0
$verifications = @(
@{ Cmd = "import framework"; Label = "framework" },
@{ Cmd = "import aden_tools"; Label = "aden_tools" }
)
# Batch verification using same check_requirements script
$verifyModules = @("framework", "aden_tools")
foreach ($v in $verifications) {
Write-Host " $([char]0x2B21) $($v.Label)... " -NoNewline
$null = & uv run python -c $v.Cmd 2>&1
if ($LASTEXITCODE -eq 0) { Write-Ok "ok" }
else { Write-Fail "failed"; $verifyErrors++ }
try {
$verifyOutput = & uv run $PythonCmd scripts/check_requirements.py @verifyModules 2>&1 | Out-String
$verifyJson = $null
try {
$verifyJson = $verifyOutput | ConvertFrom-Json
} catch {
Write-Host " Warning: Could not parse verification results" -ForegroundColor Yellow
# Fall back to basic checks if JSON parsing fails
foreach ($mod in $verifyModules) {
Write-Host " $([char]0x2B21) $mod... " -NoNewline
$null = & uv run $PythonCmd -c "import $mod" 2>&1
if ($LASTEXITCODE -eq 0) { Write-Ok "ok" }
else { Write-Fail "failed"; $verifyErrors++ }
}
}
if ($verifyJson) {
Write-Host " $([char]0x2B21) framework... " -NoNewline
if ($verifyJson.framework -eq "ok") { Write-Ok "ok" }
else { Write-Fail "failed"; $verifyErrors++ }
Write-Host " $([char]0x2B21) aden_tools... " -NoNewline
if ($verifyJson.aden_tools -eq "ok") { Write-Ok "ok" }
else { Write-Fail "failed"; $verifyErrors++ }
}
} catch {
Write-Host " Warning: Verification check encountered an error" -ForegroundColor Yellow
}
Write-Host " $([char]0x2B21) litellm... " -NoNewline
+350 -257
View File
@@ -173,6 +173,60 @@ UV_VERSION=$(uv --version)
echo -e "${GREEN} ✓ uv detected: $UV_VERSION${NC}"
echo ""
# Check for Node.js (needed for frontend dashboard)
NODE_AVAILABLE=false
if command -v node &> /dev/null; then
NODE_VERSION=$(node --version)
NODE_MAJOR=$(echo "$NODE_VERSION" | sed 's/v//' | cut -d. -f1)
if [ "$NODE_MAJOR" -ge 20 ]; then
echo -e "${GREEN} ✓ Node.js $NODE_VERSION${NC}"
NODE_AVAILABLE=true
else
echo -e "${YELLOW} ⚠ Node.js $NODE_VERSION found (20+ required for frontend)${NC}"
echo -e "${YELLOW} Installing Node.js 20 via nvm...${NC}"
# Install nvm if not present
if [ -z "${NVM_DIR:-}" ] || [ ! -s "$NVM_DIR/nvm.sh" ]; then
export NVM_DIR="$HOME/.nvm"
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash 2>/dev/null
fi
# Source nvm and install Node 20
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
if nvm install 20 > /dev/null 2>&1 && nvm use 20 > /dev/null 2>&1; then
NODE_VERSION=$(node --version)
echo -e "${GREEN} ✓ Node.js $NODE_VERSION installed via nvm${NC}"
NODE_AVAILABLE=true
else
echo -e "${RED} ✗ Node.js installation failed${NC}"
echo -e "${DIM} Install manually from https://nodejs.org${NC}"
fi
fi
else
echo -e "${YELLOW} Node.js not found. Installing via nvm...${NC}"
# Install nvm if not present
if [ -z "${NVM_DIR:-}" ] || [ ! -s "$NVM_DIR/nvm.sh" ]; then
export NVM_DIR="$HOME/.nvm"
if ! curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh 2>/dev/null | bash 2>/dev/null; then
echo -e "${RED} ✗ nvm installation failed${NC}"
echo -e "${DIM} Install Node.js 20+ manually from https://nodejs.org${NC}"
fi
fi
# Source nvm and install Node 20
if [ -s "${NVM_DIR:-$HOME/.nvm}/nvm.sh" ]; then
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
. "$NVM_DIR/nvm.sh"
if nvm install 20 > /dev/null 2>&1 && nvm use 20 > /dev/null 2>&1; then
NODE_VERSION=$(node --version)
echo -e "${GREEN} ✓ Node.js $NODE_VERSION installed via nvm${NC}"
NODE_AVAILABLE=true
else
echo -e "${RED} ✗ Node.js installation failed${NC}"
echo -e "${DIM} Install manually from https://nodejs.org${NC}"
fi
fi
fi
echo ""
# ============================================================
# Step 2: Install Python Packages
# ============================================================
@@ -216,6 +270,37 @@ echo ""
echo -e "${GREEN}${NC} All packages installed"
echo ""
# Build frontend (if Node.js is available)
FRONTEND_BUILT=false
if [ "$NODE_AVAILABLE" = true ]; then
echo -e "${YELLOW}${NC} ${BLUE}${BOLD}Building frontend dashboard...${NC}"
echo ""
FRONTEND_DIR="$SCRIPT_DIR/core/frontend"
if [ -f "$FRONTEND_DIR/package.json" ]; then
echo -n " Installing npm packages... "
if (cd "$FRONTEND_DIR" && npm install --no-fund --no-audit) > /dev/null 2>&1; then
echo -e "${GREEN}ok${NC}"
else
echo -e "${RED}failed${NC}"
NODE_AVAILABLE=false
fi
if [ "$NODE_AVAILABLE" = true ]; then
echo -n " Building frontend... "
if (cd "$FRONTEND_DIR" && npm run build) > /dev/null 2>&1; then
echo -e "${GREEN}ok${NC}"
echo -e "${GREEN} ✓ Frontend built → core/frontend/dist/${NC}"
FRONTEND_BUILT=true
else
echo -e "${RED}failed${NC}"
echo -e "${YELLOW} ⚠ Frontend build failed. The web dashboard won't be available.${NC}"
echo -e "${DIM} Run 'cd core/frontend && npm run build' manually to debug.${NC}"
fi
fi
fi
echo ""
fi
# ============================================================
# Step 3: Configure LLM API Key
# ============================================================
@@ -232,32 +317,48 @@ echo ""
IMPORT_ERRORS=0
# Test imports using workspace venv via uv run
if uv run python -c "import framework" > /dev/null 2>&1; then
echo -e "${GREEN} ✓ framework imports OK${NC}"
else
echo -e "${RED} ✗ framework import failed${NC}"
IMPORT_ERRORS=$((IMPORT_ERRORS + 1))
fi
# Batch check all imports in single process (reduces subprocess spawning overhead)
CHECK_RESULT=$(uv run python scripts/check_requirements.py framework aden_tools litellm framework.mcp.agent_builder_server 2>&1)
CHECK_EXIT=$?
if uv run python -c "import aden_tools" > /dev/null 2>&1; then
echo -e "${GREEN} ✓ aden_tools imports OK${NC}"
else
echo -e "${RED} ✗ aden_tools import failed${NC}"
IMPORT_ERRORS=$((IMPORT_ERRORS + 1))
fi
# Parse and display results
if [ $CHECK_EXIT -eq 0 ] || echo "$CHECK_RESULT" | grep -q "^{"; then
# Try to parse JSON and display formatted results
echo "$CHECK_RESULT" | uv run python -c "
import json, sys
if uv run python -c "import litellm" > /dev/null 2>&1; then
echo -e "${GREEN} ✓ litellm imports OK${NC}"
else
echo -e "${YELLOW} ⚠ litellm import issues (may be OK)${NC}"
fi
GREEN, RED, YELLOW, NC = '\033[0;32m', '\033[0;31m', '\033[1;33m', '\033[0m'
if uv run python -c "from framework.mcp import agent_builder_server" > /dev/null 2>&1; then
echo -e "${GREEN} ✓ MCP server module OK${NC}"
try:
data = json.loads(sys.stdin.read())
modules = [
('framework', 'framework imports OK', True),
('aden_tools', 'aden_tools imports OK', True),
('litellm', 'litellm imports OK', False),
('framework.mcp.agent_builder_server', 'MCP server module OK', True)
]
import_errors = 0
for mod, label, required in modules:
status = data.get(mod, 'error: not checked')
if status == 'ok':
print(f'{GREEN} ✓ {label}{NC}')
elif required:
print(f'{RED} ✗ {label} failed{NC}')
if status != 'error: not checked':
print(f' {status}')
import_errors += 1
else:
print(f'{YELLOW} ⚠ {label} (may be OK){NC}')
sys.exit(import_errors)
except json.JSONDecodeError:
print(f'{RED}Error: Could not parse import check results{NC}', file=sys.stderr)
sys.exit(1)
" 2>&1
IMPORT_ERRORS=$?
else
echo -e "${RED}MCP server module failed${NC}"
IMPORT_ERRORS=$((IMPORT_ERRORS + 1))
echo -e "${RED}Import check failed${NC}"
echo "$CHECK_RESULT"
IMPORT_ERRORS=1
fi
if [ $IMPORT_ERRORS -gt 0 ]; then
@@ -614,7 +715,7 @@ prompt_model_selection() {
}
# Function to save configuration
# Args: provider_id env_var model max_tokens [use_claude_code_sub] [api_base]
# Args: provider_id env_var model max_tokens [use_claude_code_sub] [api_base] [use_codex_sub]
save_configuration() {
local provider_id="$1"
local env_var="$2"
@@ -622,6 +723,7 @@ save_configuration() {
local max_tokens="$4"
local use_claude_code_sub="${5:-}"
local api_base="${6:-}"
local use_codex_sub="${7:-}"
# Fallbacks if not provided
if [ -z "$model" ]; then
@@ -648,6 +750,10 @@ if '$use_claude_code_sub' == 'true':
config['llm']['use_claude_code_subscription'] = True
# No api_key_env_var needed for Claude Code subscription
config['llm'].pop('api_key_env_var', None)
if '$use_codex_sub' == 'true':
config['llm']['use_codex_subscription'] = True
# No api_key_env_var needed for Codex subscription
config['llm'].pop('api_key_env_var', None)
if '$api_base':
config['llm']['api_base'] = '$api_base'
with open('$HIVE_CONFIG_FILE', 'w') as f:
@@ -671,247 +777,213 @@ SELECTED_PROVIDER_ID="" # Will hold the chosen provider ID
SELECTED_ENV_VAR="" # Will hold the chosen env var
SELECTED_MODEL="" # Will hold the chosen model ID
SELECTED_MAX_TOKENS=8192 # Will hold the chosen max_tokens
SUBSCRIPTION_MODE="" # "claude_code" | "zai_code" | ""
SUBSCRIPTION_MODE="" # "claude_code" | "codex" | "zai_code" | ""
# ── Subscription mode detection ──────────────────────────────
# Claude Code subscription: default when ~/.claude/.credentials.json exists
CLAUDE_CRED_FILE="$HOME/.claude/.credentials.json"
if [ -f "$CLAUDE_CRED_FILE" ]; then
echo -e " ${GREEN}${NC} Claude Code subscription detected"
echo -e " ${DIM}~/.claude/.credentials.json${NC}"
echo -e " ${DIM}Default: claude-opus-4-6 | max_tokens: 32768${NC}"
echo ""
if prompt_yes_no "Use Claude Code subscription? (no API key needed)"; then
SUBSCRIPTION_MODE="claude_code"
SELECTED_PROVIDER_ID="anthropic"
SELECTED_MODEL="claude-opus-4-6"
SELECTED_MAX_TOKENS=32768
echo ""
echo -e "${GREEN}${NC} Using Claude Code subscription"
fi
# ── Credential detection (silent — just set flags) ───────────
CLAUDE_CRED_DETECTED=false
if [ -f "$HOME/.claude/.credentials.json" ]; then
CLAUDE_CRED_DETECTED=true
fi
# ZAI Code subscription: check for ZAI_API_KEY
if [ -z "$SUBSCRIPTION_MODE" ] && [ -n "${ZAI_API_KEY:-}" ]; then
echo -e " ${GREEN}${NC} Found ZAI Code API key"
echo ""
if prompt_yes_no "Use your ZAI Code subscription?"; then
SUBSCRIPTION_MODE="zai_code"
SELECTED_PROVIDER_ID="openai"
SELECTED_ENV_VAR="ZAI_API_KEY"
SELECTED_MODEL="glm-5"
SELECTED_MAX_TOKENS=32768
echo ""
echo -e "${GREEN}${NC} Using ZAI Code subscription"
echo -e " ${DIM}Model: glm-5 | API: api.z.ai${NC}"
fi
CODEX_CRED_DETECTED=false
if command -v security &>/dev/null && security find-generic-password -s "Codex Auth" &>/dev/null 2>&1; then
CODEX_CRED_DETECTED=true
elif [ -f "$HOME/.codex/auth.json" ]; then
CODEX_CRED_DETECTED=true
fi
# Skip normal provider detection if a subscription mode was selected
if [ -n "$SUBSCRIPTION_MODE" ]; then
# Jump ahead — SELECTED_PROVIDER_ID is already set
:
elif [ "$USE_ASSOC_ARRAYS" = true ]; then
# Bash 4+ - iterate over associative array keys
ZAI_CRED_DETECTED=false
if [ -n "${ZAI_API_KEY:-}" ]; then
ZAI_CRED_DETECTED=true
fi
# Detect API key providers
if [ "$USE_ASSOC_ARRAYS" = true ]; then
for env_var in "${!PROVIDER_NAMES[@]}"; do
value="${!env_var}"
if [ -n "$value" ]; then
if [ -n "${!env_var}" ]; then
FOUND_PROVIDERS+=("$(get_provider_name "$env_var")")
FOUND_ENV_VARS+=("$env_var")
fi
done
else
# Bash 3.2 - iterate over indexed array
for env_var in "${PROVIDER_ENV_VARS[@]}"; do
value="${!env_var}"
if [ -n "$value" ]; then
if [ -n "${!env_var}" ]; then
FOUND_PROVIDERS+=("$(get_provider_name "$env_var")")
FOUND_ENV_VARS+=("$env_var")
fi
done
fi
if [ ${#FOUND_PROVIDERS[@]} -gt 0 ]; then
echo "Found API keys:"
echo ""
for provider in "${FOUND_PROVIDERS[@]}"; do
echo -e " ${GREEN}${NC} $provider"
done
echo ""
# ── Show unified provider selection menu ─────────────────────
echo -e "${BOLD}Select your default LLM provider:${NC}"
echo ""
echo -e " ${CYAN}${BOLD}Subscription modes (no API key purchase needed):${NC}"
# Show all found providers + ZAI subscription + Other
echo -e "${BOLD}Select your default LLM provider:${NC}"
echo ""
i=1
for provider in "${FOUND_PROVIDERS[@]}"; do
echo -e " ${CYAN}$i)${NC} $provider"
i=$((i + 1))
done
# Only show ZAI Code Subscription if the API key already exists
if [ -n "${ZAI_API_KEY:-}" ]; then
ZAI_CHOICE=$i
echo -e " ${CYAN}$i)${NC} ZAI Code Subscription ${DIM}(use your ZAI Code plan)${NC}"
i=$((i + 1))
else
ZAI_CHOICE=-1 # invalid choice, won't match
fi
echo -e " ${CYAN}$i)${NC} Other"
max_choice=$i
echo ""
while true; do
read -r -p "Enter choice (1-$max_choice): " choice || true
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "$max_choice" ]; then
if [ "$choice" -eq "$max_choice" ]; then
# Fall through to the manual provider selection below
break
elif [ "$choice" -eq "$ZAI_CHOICE" ]; then
# ZAI Code Subscription
SUBSCRIPTION_MODE="zai_code"
SELECTED_PROVIDER_ID="openai"
SELECTED_ENV_VAR="ZAI_API_KEY"
SELECTED_MODEL="glm-5"
SELECTED_MAX_TOKENS=32768
echo ""
echo -e "${GREEN}${NC} Using ZAI Code subscription"
echo -e " ${DIM}Model: glm-5 | API: api.z.ai${NC}"
break
fi
idx=$((choice - 1))
SELECTED_ENV_VAR="${FOUND_ENV_VARS[$idx]}"
SELECTED_PROVIDER_ID="$(get_provider_id "$SELECTED_ENV_VAR")"
echo ""
echo -e "${GREEN}${NC} Selected: ${FOUND_PROVIDERS[$idx]}"
prompt_model_selection "$SELECTED_PROVIDER_ID"
break
fi
echo -e "${RED}Invalid choice. Please enter 1-$max_choice${NC}"
done
# 1) Claude Code
if [ "$CLAUDE_CRED_DETECTED" = true ]; then
echo -e " ${CYAN}1)${NC} Claude Code Subscription ${DIM}(use your Claude Max/Pro plan)${NC} ${GREEN}(credential detected)${NC}"
else
echo -e " ${CYAN}1)${NC} Claude Code Subscription ${DIM}(use your Claude Max/Pro plan)${NC}"
fi
if [ -z "$SELECTED_PROVIDER_ID" ]; then
echo ""
echo -e "${BOLD}Select your LLM provider:${NC}"
echo ""
echo -e " ${CYAN}${BOLD}Subscription modes (no API key purchase needed):${NC}"
echo -e " ${CYAN}1)${NC} Claude Code Subscription ${DIM}(use your Claude Max/Pro plan)${NC}"
# 2) ZAI Code
if [ "$ZAI_CRED_DETECTED" = true ]; then
echo -e " ${CYAN}2)${NC} ZAI Code Subscription ${DIM}(use your ZAI Code plan)${NC} ${GREEN}(credential detected)${NC}"
else
echo -e " ${CYAN}2)${NC} ZAI Code Subscription ${DIM}(use your ZAI Code plan)${NC}"
echo ""
echo -e " ${CYAN}${BOLD}API key providers:${NC}"
echo -e " ${CYAN}3)${NC} Anthropic (Claude) - Recommended"
echo -e " ${CYAN}4)${NC} OpenAI (GPT)"
echo -e " ${CYAN}5)${NC} Google Gemini - Free tier available"
echo -e " ${CYAN}6)${NC} Groq - Fast, free tier"
echo -e " ${CYAN}7)${NC} Cerebras - Fast, free tier"
echo -e " ${CYAN}8)${NC} Skip for now"
echo ""
fi
while true; do
read -r -p "Enter choice (1-8): " choice || true
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le 8 ]; then
break
# 3) Codex
if [ "$CODEX_CRED_DETECTED" = true ]; then
echo -e " ${CYAN}3)${NC} OpenAI Codex Subscription ${DIM}(use your Codex/ChatGPT Plus plan)${NC} ${GREEN}(credential detected)${NC}"
else
echo -e " ${CYAN}3)${NC} OpenAI Codex Subscription ${DIM}(use your Codex/ChatGPT Plus plan)${NC}"
fi
echo ""
echo -e " ${CYAN}${BOLD}API key providers:${NC}"
# 4-8) API key providers — show (credential detected) if key already set
PROVIDER_MENU_ENVS=(ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY GROQ_API_KEY CEREBRAS_API_KEY)
PROVIDER_MENU_NAMES=("Anthropic (Claude) - Recommended" "OpenAI (GPT)" "Google Gemini - Free tier available" "Groq - Fast, free tier" "Cerebras - Fast, free tier")
for idx in 0 1 2 3 4; do
num=$((idx + 4))
if [ -n "${!PROVIDER_MENU_ENVS[$idx]}" ]; then
echo -e " ${CYAN}$num)${NC} ${PROVIDER_MENU_NAMES[$idx]} ${GREEN}(credential detected)${NC}"
else
echo -e " ${CYAN}$num)${NC} ${PROVIDER_MENU_NAMES[$idx]}"
fi
done
echo -e " ${CYAN}9)${NC} Skip for now"
echo ""
while true; do
read -r -p "Enter choice (1-9): " choice || true
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le 9 ]; then
break
fi
echo -e "${RED}Invalid choice. Please enter 1-9${NC}"
done
case $choice in
1)
# Claude Code Subscription
if [ "$CLAUDE_CRED_DETECTED" = false ]; then
echo ""
echo -e "${YELLOW} ~/.claude/.credentials.json not found.${NC}"
echo -e " Run ${CYAN}claude${NC} first to authenticate with your Claude subscription,"
echo -e " then run this quickstart again."
echo ""
SELECTED_PROVIDER_ID=""
else
SUBSCRIPTION_MODE="claude_code"
SELECTED_PROVIDER_ID="anthropic"
SELECTED_MODEL="claude-opus-4-6"
SELECTED_MAX_TOKENS=32768
echo ""
echo -e "${GREEN}${NC} Using Claude Code subscription"
fi
echo -e "${RED}Invalid choice. Please enter 1-8${NC}"
done
case $choice in
1)
# Claude Code Subscription
CLAUDE_CRED_FILE="$HOME/.claude/.credentials.json"
if [ ! -f "$CLAUDE_CRED_FILE" ]; then
;;
2)
# ZAI Code Subscription
SUBSCRIPTION_MODE="zai_code"
SELECTED_PROVIDER_ID="openai"
SELECTED_ENV_VAR="ZAI_API_KEY"
SELECTED_MODEL="glm-5"
SELECTED_MAX_TOKENS=32768
PROVIDER_NAME="ZAI"
echo ""
echo -e "${GREEN}${NC} Using ZAI Code subscription"
echo -e " ${DIM}Model: glm-5 | API: api.z.ai${NC}"
;;
3)
# OpenAI Codex Subscription
if [ "$CODEX_CRED_DETECTED" = false ]; then
echo ""
echo -e "${YELLOW} Codex credentials not found. Starting OAuth login...${NC}"
echo ""
if uv run python "$SCRIPT_DIR/codex_oauth.py"; then
CODEX_CRED_DETECTED=true
else
echo ""
echo -e "${YELLOW} ~/.claude/.credentials.json not found.${NC}"
echo -e " Run ${CYAN}claude${NC} first to authenticate with your Claude subscription,"
echo -e " then run this quickstart again."
echo -e "${RED} OAuth login failed.${NC}"
echo -e " You can also run ${CYAN}codex${NC} to authenticate, then run this quickstart again."
echo ""
SELECTED_PROVIDER_ID=""
else
SUBSCRIPTION_MODE="claude_code"
SELECTED_PROVIDER_ID="anthropic"
echo ""
echo -e "${GREEN}${NC} Using Claude Code subscription"
fi
;;
2)
# ZAI Code Subscription
SUBSCRIPTION_MODE="zai_code"
SELECTED_PROVIDER_ID="openai"
SELECTED_ENV_VAR="ZAI_API_KEY"
SELECTED_MODEL="glm-5"
SELECTED_MAX_TOKENS=32768
PROVIDER_NAME="ZAI"
echo ""
echo -e "${GREEN}${NC} Using ZAI Code subscription"
echo -e " ${DIM}Model: glm-5 | API: api.z.ai${NC}"
;;
3)
SELECTED_ENV_VAR="ANTHROPIC_API_KEY"
SELECTED_PROVIDER_ID="anthropic"
PROVIDER_NAME="Anthropic"
SIGNUP_URL="https://console.anthropic.com/settings/keys"
;;
4)
SELECTED_ENV_VAR="OPENAI_API_KEY"
SELECTED_PROVIDER_ID="openai"
PROVIDER_NAME="OpenAI"
SIGNUP_URL="https://platform.openai.com/api-keys"
;;
5)
SELECTED_ENV_VAR="GEMINI_API_KEY"
SELECTED_PROVIDER_ID="gemini"
PROVIDER_NAME="Google Gemini"
SIGNUP_URL="https://aistudio.google.com/apikey"
;;
6)
SELECTED_ENV_VAR="GROQ_API_KEY"
SELECTED_PROVIDER_ID="groq"
PROVIDER_NAME="Groq"
SIGNUP_URL="https://console.groq.com/keys"
;;
7)
SELECTED_ENV_VAR="CEREBRAS_API_KEY"
SELECTED_PROVIDER_ID="cerebras"
PROVIDER_NAME="Cerebras"
SIGNUP_URL="https://cloud.cerebras.ai/"
;;
8)
echo ""
echo -e "${YELLOW}Skipped.${NC} An LLM API key is required to test and use worker agents."
echo -e "Add your API key later by running:"
echo ""
echo -e " ${CYAN}echo 'export ANTHROPIC_API_KEY=\"your-key\"' >> $SHELL_RC_FILE${NC}"
echo ""
SELECTED_ENV_VAR=""
SELECTED_PROVIDER_ID=""
;;
esac
# For API-key providers: prompt for key if not already set
if [ -z "$SUBSCRIPTION_MODE" ] && [ -n "$SELECTED_ENV_VAR" ] && [ -z "${!SELECTED_ENV_VAR}" ]; then
echo ""
echo -e "Get your API key from: ${CYAN}$SIGNUP_URL${NC}"
echo ""
read -r -p "Paste your $PROVIDER_NAME API key (or press Enter to skip): " API_KEY
if [ -n "$API_KEY" ]; then
# Save to shell rc file
echo "" >> "$SHELL_RC_FILE"
echo "# Hive Agent Framework - $PROVIDER_NAME API key" >> "$SHELL_RC_FILE"
echo "export $SELECTED_ENV_VAR=\"$API_KEY\"" >> "$SHELL_RC_FILE"
export "$SELECTED_ENV_VAR=$API_KEY"
echo ""
echo -e "${GREEN}${NC} API key saved to $SHELL_RC_FILE"
else
echo ""
echo -e "${YELLOW}Skipped.${NC} Add your API key to $SHELL_RC_FILE when ready."
SELECTED_ENV_VAR=""
SELECTED_PROVIDER_ID=""
fi
fi
if [ "$CODEX_CRED_DETECTED" = true ]; then
SUBSCRIPTION_MODE="codex"
SELECTED_PROVIDER_ID="openai"
SELECTED_MODEL="gpt-5.3-codex"
SELECTED_MAX_TOKENS=16384
echo ""
echo -e "${GREEN}${NC} Using OpenAI Codex subscription"
fi
;;
4)
SELECTED_ENV_VAR="ANTHROPIC_API_KEY"
SELECTED_PROVIDER_ID="anthropic"
PROVIDER_NAME="Anthropic"
SIGNUP_URL="https://console.anthropic.com/settings/keys"
;;
5)
SELECTED_ENV_VAR="OPENAI_API_KEY"
SELECTED_PROVIDER_ID="openai"
PROVIDER_NAME="OpenAI"
SIGNUP_URL="https://platform.openai.com/api-keys"
;;
6)
SELECTED_ENV_VAR="GEMINI_API_KEY"
SELECTED_PROVIDER_ID="gemini"
PROVIDER_NAME="Google Gemini"
SIGNUP_URL="https://aistudio.google.com/apikey"
;;
7)
SELECTED_ENV_VAR="GROQ_API_KEY"
SELECTED_PROVIDER_ID="groq"
PROVIDER_NAME="Groq"
SIGNUP_URL="https://console.groq.com/keys"
;;
8)
SELECTED_ENV_VAR="CEREBRAS_API_KEY"
SELECTED_PROVIDER_ID="cerebras"
PROVIDER_NAME="Cerebras"
SIGNUP_URL="https://cloud.cerebras.ai/"
;;
9)
echo ""
echo -e "${YELLOW}Skipped.${NC} An LLM API key is required to test and use worker agents."
echo -e "Add your API key later by running:"
echo ""
echo -e " ${CYAN}echo 'export ANTHROPIC_API_KEY=\"your-key\"' >> $SHELL_RC_FILE${NC}"
echo ""
SELECTED_ENV_VAR=""
SELECTED_PROVIDER_ID=""
;;
esac
# For API-key providers: prompt for key if not already set
if [ -z "$SUBSCRIPTION_MODE" ] && [ -n "$SELECTED_ENV_VAR" ] && [ -z "${!SELECTED_ENV_VAR}" ]; then
echo ""
echo -e "Get your API key from: ${CYAN}$SIGNUP_URL${NC}"
echo ""
read -r -p "Paste your $PROVIDER_NAME API key (or press Enter to skip): " API_KEY
if [ -n "$API_KEY" ]; then
echo "" >> "$SHELL_RC_FILE"
echo "# Hive Agent Framework - $PROVIDER_NAME API key" >> "$SHELL_RC_FILE"
echo "export $SELECTED_ENV_VAR=\"$API_KEY\"" >> "$SHELL_RC_FILE"
export "$SELECTED_ENV_VAR=$API_KEY"
echo ""
echo -e "${GREEN}${NC} API key saved to $SHELL_RC_FILE"
else
echo ""
echo -e "${YELLOW}Skipped.${NC} Add your API key to $SHELL_RC_FILE when ready."
SELECTED_ENV_VAR=""
SELECTED_PROVIDER_ID=""
fi
fi
# For ZAI subscription: always prompt for API key
@@ -947,6 +1019,8 @@ if [ -n "$SELECTED_PROVIDER_ID" ]; then
echo -n " Saving configuration... "
if [ "$SUBSCRIPTION_MODE" = "claude_code" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "true" "" > /dev/null
elif [ "$SUBSCRIPTION_MODE" = "codex" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "" "" "true" > /dev/null
elif [ "$SUBSCRIPTION_MODE" = "zai_code" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "" "https://api.z.ai/api/coding/paas/v4" > /dev/null
else
@@ -1104,6 +1178,13 @@ else
echo -e "${YELLOW}--${NC}"
fi
echo -n " ⬡ frontend... "
if [ -f "$SCRIPT_DIR/core/frontend/dist/index.html" ]; then
echo -e "${GREEN}ok${NC}"
else
echo -e "${YELLOW}--${NC}"
fi
echo ""
if [ $ERRORS -gt 0 ]; then
@@ -1208,27 +1289,39 @@ if [ "$CODEX_AVAILABLE" = true ]; then
echo ""
fi
# Prompt user to source shell config or start new terminal
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}⚠️ IMPORTANT: Load your new configuration${NC}"
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e " Your API keys have been saved to ${CYAN}$SHELL_RC_FILE${NC}"
echo -e " To use them, either:"
echo ""
echo -e " ${GREEN}Option 1:${NC} Source your shell config now:"
echo -e " ${CYAN}source $SHELL_RC_FILE${NC}"
echo ""
echo -e " ${GREEN}Option 2:${NC} Open a new terminal window"
echo ""
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
# Auto-launch dashboard if frontend was built
if [ "$FRONTEND_BUILT" = true ]; then
echo -e "${BOLD}Launching dashboard...${NC}"
echo ""
echo -e " ${DIM}Starting server on http://localhost:8787${NC}"
echo -e " ${DIM}Press Ctrl+C to stop${NC}"
echo ""
# exec replaces the quickstart process with hive serve
# --open tells it to auto-open the browser once the server is ready
exec "$SCRIPT_DIR/hive" serve --open
else
# No frontend — show manual instructions
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}⚠️ IMPORTANT: Load your new configuration${NC}"
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e " Your API keys have been saved to ${CYAN}$SHELL_RC_FILE${NC}"
echo -e " To use them, either:"
echo ""
echo -e " ${GREEN}Option 1:${NC} Source your shell config now:"
echo -e " ${CYAN}source $SHELL_RC_FILE${NC}"
echo ""
echo -e " ${GREEN}Option 2:${NC} Open a new terminal window"
echo ""
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${BOLD}Run an Agent:${NC}"
echo ""
echo -e " Launch the interactive dashboard to browse and run agents:"
echo -e " You can start an example agent or an agent built by yourself:"
echo -e " ${CYAN}hive tui${NC}"
echo ""
echo -e "${DIM}Run ./quickstart.sh again to reconfigure.${NC}"
echo ""
echo -e "${BOLD}Run an Agent:${NC}"
echo ""
echo -e " Launch the interactive dashboard to browse and run agents:"
echo -e " You can start an example agent or an agent built by yourself:"
echo -e " ${CYAN}hive tui${NC}"
echo ""
echo -e "${DIM}Run ./quickstart.sh again to reconfigure.${NC}"
echo ""
fi
+102
View File
@@ -0,0 +1,102 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Benchmark script to measure import check performance
.DESCRIPTION
Measures the time taken for import checks using both the old
(individual subprocess) and new (batched) approaches.
.EXAMPLE
.\scripts\benchmark_quickstart.ps1
#>
$ErrorActionPreference = "Stop"
# Get the directory where this script lives
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$ProjectRoot = Split-Path -Parent $ScriptDir
Write-Host ""
Write-Host "=== Import Check Performance Benchmark ===" -ForegroundColor Cyan
Write-Host ""
# Find Python
$PythonCmd = $null
foreach ($candidate in @("python3.13", "python3.12", "python3.11", "python3", "python")) {
try {
$ver = & $candidate -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null
if ($LASTEXITCODE -eq 0 -and $ver) {
$parts = $ver.Split(".")
$major = [int]$parts[0]
$minor = [int]$parts[1]
if ($major -eq 3 -and $minor -ge 11) {
$PythonCmd = $candidate
break
}
}
} catch {
# candidate not found, continue
}
}
if (-not $PythonCmd) {
Write-Host "Python 3.11+ not found. Please install Python and try again." -ForegroundColor Red
exit 1
}
Write-Host "Using Python: $PythonCmd" -ForegroundColor Green
Write-Host ""
# Define modules to check
$modules = @("framework", "aden_tools", "litellm", "framework.mcp.agent_builder_server")
# Benchmark old approach (individual subprocess calls)
Write-Host "Testing OLD approach (individual subprocess calls)..." -ForegroundColor Yellow
$oldTimes = @()
for ($i = 0; $i -lt 3; $i++) {
$elapsed = Measure-Command {
foreach ($module in $modules) {
# Use 'python' instead of the detected command for uv run on Windows
$null = & uv run python -c "import $module" 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error "Installation failed: Could not import $module"
exit 1
}
}
}
$oldTimes += $elapsed.TotalMilliseconds
Write-Host " Run $($i + 1): $([math]::Round($elapsed.TotalMilliseconds, 2)) ms"
}
$oldAvg = ($oldTimes | Measure-Object -Average).Average
Write-Host ""
Write-Host "OLD approach average: $([math]::Round($oldAvg, 2)) ms" -ForegroundColor Cyan
Write-Host ""
# Benchmark new approach (batched)
Write-Host "Testing NEW approach (batched import checker)..." -ForegroundColor Yellow
$newTimes = @()
for ($i = 0; $i -lt 3; $i++) {
$elapsed = Measure-Command {
# Use 'python' for uv run on Windows
$null = & uv run python scripts/check_requirements.py @modules 2>&1
}
$newTimes += $elapsed.TotalMilliseconds
Write-Host " Run $($i + 1): $([math]::Round($elapsed.TotalMilliseconds, 2)) ms"
}
$newAvg = ($newTimes | Measure-Object -Average).Average
Write-Host ""
Write-Host "NEW approach average: $([math]::Round($newAvg, 2)) ms" -ForegroundColor Cyan
Write-Host ""
# Calculate improvement
$improvement = $oldAvg - $newAvg
$improvementPercent = ($improvement / $oldAvg) * 100
Write-Host "=== Results ===" -ForegroundColor Green
Write-Host "Time saved: $([math]::Round($improvement, 2)) ms ($([math]::Round($improvementPercent, 1))% faster)" -ForegroundColor Green
Write-Host ""
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
check_requirements.py - Batch import checker for quickstart scripts
This script checks multiple Python module imports in a single process,
reducing subprocess spawning overhead significantly on Windows.
Usage:
python scripts/check_requirements.py <module1> <module2> ...
Returns:
JSON object with import status for each module
Exit code 0 if all imports succeed, 1 if any fail
"""
import json
import sys
from typing import Dict
def check_imports(modules: list[str]) -> Dict[str, str]:
"""
Attempt to import each module and return status.
Args:
modules: List of module names to check
Returns:
Dictionary mapping module name to "ok" or error message
"""
results = {}
for module_name in modules:
try:
# Handle both simple imports and from imports
if " " in module_name:
# This shouldn't happen with current usage, but handle it safely
results[module_name] = "error: invalid module name"
else:
# Try to import the module
__import__(module_name)
results[module_name] = "ok"
except ImportError as e:
results[module_name] = f"error: {str(e)}"
except Exception as e:
results[module_name] = f"error: {type(e).__name__}: {str(e)}"
return results
def main():
"""Main entry point."""
if len(sys.argv) < 2:
print(json.dumps({"error": "No modules specified"}), file=sys.stderr)
sys.exit(1)
modules_to_check = sys.argv[1:]
results = check_imports(modules_to_check)
# Print results as JSON
print(json.dumps(results, indent=2))
# Exit with error code if any imports failed
has_errors = any(status != "ok" for status in results.values())
sys.exit(1 if has_errors else 0)
if __name__ == "__main__":
main()
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
Simple test script to verify check_requirements.py works correctly
"""
import subprocess
import json
import sys
def test_check_requirements():
"""Test the check_requirements.py script"""
print("Testing check_requirements.py...")
print("=" * 60)
# Test 1: All valid modules
print("\n Test 1: All valid standard library modules")
result = subprocess.run(
[sys.executable, "scripts/check_requirements.py", "json", "sys", "os"],
capture_output=True,
text=True,
)
print(f"Exit code: {result.returncode}")
print(f"Output:\n{result.stdout}")
try:
data = json.loads(result.stdout)
assert all(v == "ok" for v in data.values()), "All modules should be 'ok'"
assert result.returncode == 0, "Exit code should be 0"
print("✓ Test 1 passed")
except Exception as e:
print(f"✗ Test 1 failed: {e}")
return False
# Test 2: Mix of valid and invalid modules
print("\n\nTest 2: Mix of valid and invalid modules")
result = subprocess.run(
[sys.executable, "scripts/check_requirements.py", "json", "nonexistent_module"],
capture_output=True,
text=True,
)
print(f"Exit code: {result.returncode}")
print(f"Output:\n{result.stdout}")
try:
data = json.loads(result.stdout)
assert data["json"] == "ok", "json should be ok"
assert "error" in data["nonexistent_module"], (
"nonexistent_module should have error"
)
assert result.returncode == 1, "Exit code should be 1 when errors exist"
print("✓ Test 2 passed")
except Exception as e:
print(f"✗ Test 2 failed: {e}")
return False
print("\n" + "=" * 60)
print("All tests passed! ✓")
return True
if __name__ == "__main__":
success = test_check_requirements()
sys.exit(0 if success else 1)
+301
View File
@@ -117,6 +117,88 @@ PROJECT_ROOT: str = ""
SNAPSHOT_DIR: str = ""
# ── Auto-commit tracking for agent versioning ─────────────────────────────
_dirty_agent_dirs: set[str] = set()
def _is_agent_dir(resolved_path: str) -> str | None:
"""If *resolved_path* is under exports/{agent}/, return the agent dir."""
exports_dir = os.path.join(PROJECT_ROOT, "exports")
if not resolved_path.startswith(exports_dir + os.sep):
return None
rel = os.path.relpath(resolved_path, exports_dir)
agent_name = rel.split(os.sep)[0]
return os.path.join(exports_dir, agent_name)
def _track_agent_write(resolved_path: str) -> None:
"""Mark an agent dir as dirty after a write/edit."""
agent_dir = _is_agent_dir(resolved_path)
if agent_dir:
_dirty_agent_dirs.add(agent_dir)
def _flush_agent_commits() -> None:
"""Commit all pending agent directory changes (called before reads)."""
if not _dirty_agent_dirs:
return
for agent_dir in list(_dirty_agent_dirs):
if os.path.isdir(agent_dir):
_git_auto_commit(agent_dir)
_dirty_agent_dirs.clear()
def _git_auto_commit(repo_dir: str) -> str | None:
"""Init repo if needed and commit all changes with an auto message."""
try:
# Init if needed
if not os.path.isdir(os.path.join(repo_dir, ".git")):
subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True)
gitignore_path = os.path.join(repo_dir, ".gitignore")
if not os.path.exists(gitignore_path):
with open(gitignore_path, "w") as f:
f.write("__pycache__/\n*.pyc\n*.pyo\n.DS_Store\n")
# Check for changes
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=repo_dir,
capture_output=True,
text=True,
)
if not result.stdout.strip():
return None
# Auto-generate message from changed files
lines = [l for l in result.stdout.strip().split("\n") if l.strip()]
changed = [l[3:].strip() for l in lines]
message = "Auto: " + ", ".join(changed[:5])
if len(changed) > 5:
message += f" (+{len(changed) - 5} more)"
# Stage and commit
subprocess.run(["git", "add", "-A"], cwd=repo_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", message],
cwd=repo_dir,
capture_output=True,
)
sha = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=repo_dir,
capture_output=True,
text=True,
)
return sha.stdout.strip()
except FileNotFoundError:
logger.warning("git not found — agent auto-commit skipped")
return None
except Exception as e:
logger.warning("agent auto-commit failed: %s", e)
return None
# ── Path resolution ───────────────────────────────────────────────────────
@@ -319,6 +401,7 @@ def read_file(path: str, offset: int = 1, limit: int = 0) -> str:
Returns:
File contents with line numbers, or error message
"""
_flush_agent_commits()
resolved = _resolve_path(path)
if os.path.isdir(resolved):
@@ -408,6 +491,7 @@ def write_file(path: str, content: str) -> str:
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
action = "Updated" if existed else "Created"
_track_agent_write(resolved)
return f"{action} {path} ({len(content):,} bytes, {line_count} lines)"
except Exception as e:
return f"Error writing file: {e}"
@@ -502,6 +586,7 @@ def edit_file(path: str, old_text: str, new_text: str, replace_all: bool = False
result = f"Replaced {count} occurrence(s) in {path}{match_info}"
if diff:
result += f"\n\n{diff}"
_track_agent_write(resolved)
return result
except Exception as e:
return f"Error editing file: {e}"
@@ -521,6 +606,7 @@ def list_directory(path: str = ".", recursive: bool = False) -> str:
Returns:
Sorted directory listing with / suffix for directories
"""
_flush_agent_commits()
resolved = _resolve_path(path)
if not os.path.isdir(resolved):
return f"Error: Directory not found: {path}"
@@ -579,6 +665,7 @@ def search_files(pattern: str, path: str = ".", include: str = "") -> str:
Returns:
Matching lines grouped by file with line numbers
"""
_flush_agent_commits()
resolved = _resolve_path(path)
if not os.path.isdir(resolved):
return f"Error: Directory not found: {path}"
@@ -668,6 +755,7 @@ def run_command(command: str, cwd: str = "", timeout: int = 120) -> str:
Returns:
Combined stdout/stderr with exit code
"""
_flush_agent_commits()
timeout = min(timeout, 300) # Cap at 5 minutes
work_dir = _resolve_path(cwd) if cwd else PROJECT_ROOT
@@ -884,6 +972,218 @@ def discover_mcp_tools(server_config_path: str = "") -> str:
return json.dumps(result, indent=2, default=str)
# ── Meta-agent: Agent tool catalog ────────────────────────────────────────
@mcp.tool()
def list_agent_tools(server_config_path: str = "") -> str:
"""List all tools available for agent building from the hive-tools MCP server.
Returns tool names grouped by category. Use this BEFORE designing an agent
to know exactly which tools exist. Only use tools from this list in node
definitions never guess or fabricate tool names.
Args:
server_config_path: Path to mcp_servers.json. Default: tools/mcp_servers.json
(the standard hive-tools server). Can also point to an agent's config
to see what tools that specific agent has access to.
Returns:
JSON with tool names grouped by prefix (e.g. gmail_*, slack_*, etc.)
"""
# Resolve config path
if not server_config_path:
candidates = [
os.path.join(PROJECT_ROOT, "tools", "mcp_servers.json"),
os.path.join(PROJECT_ROOT, "mcp_servers.json"),
]
config_path = None
for c in candidates:
if os.path.isfile(c):
config_path = c
break
if not config_path:
return json.dumps({"error": "No mcp_servers.json found"})
else:
config_path = _resolve_path(server_config_path)
if not os.path.isfile(config_path):
return json.dumps({"error": f"Config not found: {server_config_path}"})
try:
with open(config_path, encoding="utf-8") as f:
servers_config = json.load(f)
except (json.JSONDecodeError, OSError) as e:
return json.dumps({"error": f"Failed to read config: {e}"})
try:
from framework.runner.mcp_client import MCPClient, MCPServerConfig
except ImportError:
return json.dumps({"error": "Cannot import MCPClient"})
all_tools: list[dict] = []
errors = []
config_dir = os.path.dirname(config_path)
for server_name, server_conf in servers_config.items():
cwd = server_conf.get("cwd", "")
if cwd and not os.path.isabs(cwd):
cwd = os.path.abspath(os.path.join(config_dir, cwd))
try:
config = MCPServerConfig(
name=server_name,
transport=server_conf.get("transport", "stdio"),
command=server_conf.get("command"),
args=server_conf.get("args", []),
env=server_conf.get("env", {}),
cwd=cwd or None,
url=server_conf.get("url"),
headers=server_conf.get("headers", {}),
)
client = MCPClient(config)
client.connect()
for tool in client.list_tools():
all_tools.append({"name": tool.name, "description": tool.description})
client.disconnect()
except Exception as e:
errors.append({"server": server_name, "error": str(e)})
# Group by prefix (e.g., gmail_, slack_, stripe_)
groups: dict[str, list[str]] = {}
for t in sorted(all_tools, key=lambda x: x["name"]):
parts = t["name"].split("_", 1)
prefix = parts[0] if len(parts) > 1 else "general"
groups.setdefault(prefix, []).append(t["name"])
result: dict = {
"total": len(all_tools),
"tools_by_category": groups,
"all_tool_names": sorted(t["name"] for t in all_tools),
}
if errors:
result["errors"] = errors
return json.dumps(result, indent=2)
# ── Meta-agent: Agent tool validation ─────────────────────────────────────
@mcp.tool()
def validate_agent_tools(agent_path: str) -> str:
"""Validate that all tools declared in an agent's nodes exist in its MCP servers.
Connects to the agent's configured MCP servers, discovers available tools,
then checks every node's declared tools against what actually exists.
Use this after building an agent to catch hallucinated or misspelled tool names.
Args:
agent_path: Path to agent directory (e.g. "exports/my_agent")
Returns:
JSON with validation result: pass/fail, missing tools per node, available tools
"""
resolved = _resolve_path(agent_path)
if not os.path.isdir(resolved):
return json.dumps({"error": f"Agent directory not found: {agent_path}"})
# --- Discover available tools from agent's MCP servers ---
mcp_config_path = os.path.join(resolved, "mcp_servers.json")
if not os.path.isfile(mcp_config_path):
return json.dumps({"error": f"No mcp_servers.json found in {agent_path}"})
try:
from framework.runner.mcp_client import MCPClient, MCPServerConfig
except ImportError:
return json.dumps({"error": "Cannot import MCPClient"})
available_tools: set[str] = set()
discovery_errors = []
config_dir = os.path.dirname(mcp_config_path)
try:
with open(mcp_config_path, encoding="utf-8") as f:
servers_config = json.load(f)
except (json.JSONDecodeError, OSError) as e:
return json.dumps({"error": f"Failed to read mcp_servers.json: {e}"})
for server_name, server_conf in servers_config.items():
cwd = server_conf.get("cwd", "")
if cwd and not os.path.isabs(cwd):
cwd = os.path.abspath(os.path.join(config_dir, cwd))
try:
config = MCPServerConfig(
name=server_name,
transport=server_conf.get("transport", "stdio"),
command=server_conf.get("command"),
args=server_conf.get("args", []),
env=server_conf.get("env", {}),
cwd=cwd or None,
url=server_conf.get("url"),
headers=server_conf.get("headers", {}),
)
client = MCPClient(config)
client.connect()
for tool in client.list_tools():
available_tools.add(tool.name)
client.disconnect()
except Exception as e:
discovery_errors.append({"server": server_name, "error": str(e)})
# --- Load agent nodes and extract declared tools ---
agent_py = os.path.join(resolved, "agent.py")
if not os.path.isfile(agent_py):
return json.dumps({"error": f"No agent.py found in {agent_path}"})
import importlib
import importlib.util
import sys
package_name = os.path.basename(resolved)
parent_dir = os.path.dirname(os.path.abspath(resolved))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
try:
agent_module = importlib.import_module(package_name)
except Exception as e:
return json.dumps({"error": f"Failed to import agent: {e}"})
nodes = getattr(agent_module, "nodes", None)
if not nodes:
return json.dumps({"error": "Agent module has no 'nodes' attribute"})
# --- Validate declared vs available ---
missing_by_node: dict[str, list[str]] = {}
for node in nodes:
node_tools = getattr(node, "tools", None) or []
missing = [t for t in node_tools if t not in available_tools]
if missing:
node_name = getattr(node, "name", None) or getattr(node, "id", "unknown")
node_id = getattr(node, "id", "unknown")
missing_by_node[f"{node_name} (id={node_id})"] = sorted(missing)
result: dict = {
"valid": len(missing_by_node) == 0,
"agent": agent_path,
"available_tool_count": len(available_tools),
}
if missing_by_node:
result["missing_tools"] = missing_by_node
result["message"] = (
f"FAIL: {sum(len(v) for v in missing_by_node.values())} tool(s) declared "
f"in nodes do not exist. Run discover_mcp_tools() to see available tools "
f"and fix the node definitions."
)
else:
result["message"] = "PASS: All declared tools exist in the agent's MCP servers."
if discovery_errors:
result["discovery_errors"] = discovery_errors
return json.dumps(result, indent=2)
# ── Meta-agent: Agent inventory ───────────────────────────────────────────
@@ -905,6 +1205,7 @@ def list_agents() -> str:
scan_dirs = [
(os.path.join(PROJECT_ROOT, "core", "framework", "agents"), "framework"),
(os.path.join(PROJECT_ROOT, "exports"), "user"),
(os.path.join(PROJECT_ROOT, "examples", "templates"), "example"),
]
for scan_dir, source in scan_dirs:
+1 -1
View File
@@ -11,7 +11,7 @@ EMAIL_CREDENTIALS = {
env_var="RESEND_API_KEY",
tools=["send_email"],
node_types=[],
required=False,
required=True,
startup_required=False,
help_url="https://resend.com/api-keys",
description="API key for Resend email service",
@@ -35,7 +35,8 @@ Returns error dicts for common issues:
- `HTTP <status>: Failed to fetch URL` - Server returned error status
- `Navigation failed: no response received` - Browser could not navigate to URL
- `No elements found matching selector: <selector>` - CSS selector matched nothing
- `Request timed out` - Page load exceeded 30s timeout
- `Request timed out` - Page load exceeded 60s timeout
- `Blocked by robots.txt: <url>` - URL disallowed by site's robots.txt
- `Browser error: <error>` - Playwright/Chromium error
- `Scraping failed: <error>` - HTML parsing or other error
@@ -47,4 +48,4 @@ Returns error dicts for common issues:
- Waits for `networkidle` before extracting content
- Removes script, style, nav, footer, header, aside, noscript, and iframe elements
- Auto-detects main content using article, main, or common content class selectors
- Respects robots.txt by default (uses httpx for lightweight robots.txt fetching)
- Respects robots.txt by default (set `respect_robots_txt=False` to disable)
@@ -9,7 +9,8 @@ Uses BeautifulSoup for HTML parsing and content extraction.
from __future__ import annotations
from typing import Any
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
from urllib.robotparser import RobotFileParser
from bs4 import BeautifulSoup
from fastmcp import FastMCP
@@ -37,6 +38,7 @@ def register_tools(mcp: FastMCP) -> None:
selector: str | None = None,
include_links: bool = False,
max_length: int = 50000,
respect_robots_txt: bool = True,
) -> dict:
"""
Scrape and extract text content from a webpage.
@@ -50,6 +52,7 @@ def register_tools(mcp: FastMCP) -> None:
selector: CSS selector to target specific content (e.g., 'article', '.main-content')
include_links: Include extracted links in the response
max_length: Maximum length of extracted text (1000-500000)
respect_robots_txt: Whether to respect robots.txt rules (default True)
Returns:
Dict with scraped content (url, title, description, content, length) or error dict
@@ -62,6 +65,23 @@ def register_tools(mcp: FastMCP) -> None:
# Validate max_length
max_length = max(1000, min(max_length, 500000))
# Check robots.txt before launching browser
if respect_robots_txt:
try:
parsed = urlparse(url)
robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
rp = RobotFileParser()
rp.set_url(robots_url)
rp.read()
if not rp.can_fetch(BROWSER_USER_AGENT, url):
return {
"error": f"Blocked by robots.txt: {url}",
"url": url,
"skipped": True,
}
except Exception:
pass # If robots.txt can't be fetched, proceed anyway
# Launch headless browser with stealth
async with async_playwright() as p:
browser = await p.chromium.launch(
@@ -88,16 +108,13 @@ def register_tools(mcp: FastMCP) -> None:
timeout=60000,
)
# Give JS a moment to render dynamic content
await page.wait_for_timeout(2000)
# Validate response before waiting for JS render
if response is None:
return {"error": "Navigation failed: no response received"}
if response.status != 200:
return {"error": f"HTTP {response.status}: Failed to fetch URL"}
# Validate Content-Type
content_type = response.headers.get("content-type", "").lower()
if not any(t in content_type for t in ["text/html", "application/xhtml+xml"]):
return {
@@ -106,6 +123,12 @@ def register_tools(mcp: FastMCP) -> None:
"skipped": True,
}
# Wait for JS to finish rendering dynamic content
try:
await page.wait_for_load_state("networkidle", timeout=3000)
except PlaywrightTimeout:
pass # Proceed with whatever has loaded
# Get fully rendered HTML
html_content = await page.content()
finally:
+82 -1
View File
@@ -26,7 +26,7 @@ def _make_playwright_mocks(html, status=200, final_url="https://example.com/page
mock_page = AsyncMock()
mock_page.goto.return_value = mock_response
mock_page.content.return_value = html
mock_page.wait_for_timeout.return_value = None
mock_page.wait_for_load_state.return_value = None
mock_context = AsyncMock()
mock_context.new_page.return_value = mock_page
@@ -349,3 +349,84 @@ class TestWebScrapeToolLinkConversion:
# Empty and whitespace-only text should be filtered
assert "" not in texts
assert len([t for t in texts if not t.strip()]) == 0
class TestWebScrapeToolErrorHandling:
"""Tests for error handling and early exit before JS wait."""
@pytest.mark.asyncio
@patch(_STEALTH_PATH)
@patch(_PW_PATH)
async def test_http_error_returns_without_waiting(self, mock_pw, mock_stealth, web_scrape_fn):
"""HTTP errors return immediately without waiting for networkidle."""
html = "<html><body>Not Found</body></html>"
mock_cm, _, mock_page = _make_playwright_mocks(html, status=404)
mock_pw.return_value = mock_cm
mock_stealth.return_value.apply_stealth_async = AsyncMock()
result = await web_scrape_fn(url="https://example.com/missing")
assert result == {"error": "HTTP 404: Failed to fetch URL"}
mock_page.wait_for_load_state.assert_not_called()
@pytest.mark.asyncio
@patch(_STEALTH_PATH)
@patch(_PW_PATH)
async def test_null_response_returns_error(self, mock_pw, mock_stealth, web_scrape_fn):
"""Null navigation response returns error without waiting."""
mock_cm, _, mock_page = _make_playwright_mocks("<html></html>")
mock_pw.return_value = mock_cm
mock_stealth.return_value.apply_stealth_async = AsyncMock()
mock_page.goto.return_value = None
result = await web_scrape_fn(url="https://example.com")
assert result == {"error": "Navigation failed: no response received"}
mock_page.wait_for_load_state.assert_not_called()
@pytest.mark.asyncio
@patch(_STEALTH_PATH)
@patch(_PW_PATH)
async def test_non_html_content_type_skipped(self, mock_pw, mock_stealth, web_scrape_fn):
"""Non-HTML content types are skipped without waiting."""
mock_cm, mock_response, mock_page = _make_playwright_mocks("<html></html>")
mock_response.headers = {"content-type": "application/pdf"}
mock_pw.return_value = mock_cm
mock_stealth.return_value.apply_stealth_async = AsyncMock()
result = await web_scrape_fn(url="https://example.com/file.pdf")
assert "error" in result
assert result["skipped"] is True
mock_page.wait_for_load_state.assert_not_called()
class TestWebScrapeToolRobotsTxt:
"""Tests for robots.txt respect."""
@pytest.mark.asyncio
@patch(_STEALTH_PATH)
@patch(_PW_PATH)
@patch("aden_tools.tools.web_scrape_tool.web_scrape_tool.RobotFileParser")
async def test_blocked_by_robots_txt(self, mock_rp_cls, mock_pw, mock_stealth, web_scrape_fn):
"""URLs disallowed by robots.txt are skipped."""
mock_rp = MagicMock()
mock_rp.can_fetch.return_value = False
mock_rp_cls.return_value = mock_rp
result = await web_scrape_fn(url="https://example.com/private")
assert "error" in result
assert "robots.txt" in result["error"]
assert result["skipped"] is True
@pytest.mark.asyncio
@patch(_STEALTH_PATH)
@patch(_PW_PATH)
@patch("aden_tools.tools.web_scrape_tool.web_scrape_tool.RobotFileParser")
async def test_robots_txt_disabled(self, mock_rp_cls, mock_pw, mock_stealth, web_scrape_fn):
"""robots.txt check is skipped when respect_robots_txt=False."""
html = "<html><body>Content</body></html>"
mock_cm, _, _ = _make_playwright_mocks(html)
mock_pw.return_value = mock_cm
mock_stealth.return_value.apply_stealth_async = AsyncMock()
result = await web_scrape_fn(url="https://example.com", respect_robots_txt=False)
assert "error" not in result
mock_rp_cls.assert_not_called()