Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddefd90e4e | |||
| 145860f42e | |||
| d9f84648d0 | |||
| 9fb7e0bae7 | |||
| ead85dd41f | |||
| cf5bf6f174 | |||
| 46237e7309 | |||
| afa686b47b | |||
| 21e02c9e50 | |||
| 30a188d7c8 | |||
| 355f51b25e | |||
| 8e1cde86e8 | |||
| c13b02c7d9 | |||
| 9e72801c28 | |||
| 3a3d538b73 | |||
| b11bca0c67 | |||
| faf8975b42 | |||
| 863168880e | |||
| 384a1f0560 | |||
| 4bd1b1b9e6 | |||
| 8c3866a014 | |||
| 61283d9bd6 | |||
| 585a7186d4 | |||
| 72a31c2a65 | |||
| 10d9e54857 | |||
| e68695ee92 | |||
| 11379fc0ef | |||
| 6d102382bd | |||
| 56335927e7 | |||
| a3fe994b22 | |||
| 5754bdcc78 | |||
| 7286907cd4 | |||
| ebeac68707 | |||
| de5fcab933 | |||
| a7a2100472 | |||
| 4961d3ba8c | |||
| 40e74e408b |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 ""),
|
||||
},
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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`),
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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}` : ""}`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 → 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 → 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">
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user