diff --git a/.claude/skills/building-agents-construction/examples/online_research_agent/agent.py b/.claude/skills/building-agents-construction/examples/online_research_agent/agent.py index 1326c2e0..ea62aee1 100644 --- a/.claude/skills/building-agents-construction/examples/online_research_agent/agent.py +++ b/.claude/skills/building-agents-construction/examples/online_research_agent/agent.py @@ -218,19 +218,9 @@ class OnlineResearchAgent: tool_registry = ToolRegistry() # Load MCP servers (always load, needed for tool validation) - agent_dir = Path(__file__).parent - mcp_config_path = agent_dir / "mcp_servers.json" - + mcp_config_path = Path(__file__).parent / "mcp_servers.json" if mcp_config_path.exists(): - with open(mcp_config_path) as f: - mcp_servers = json.load(f) - - for server_config in mcp_servers.get("servers", []): - # Resolve relative cwd paths - cwd = server_config.get("cwd") - if cwd and not Path(cwd).is_absolute(): - server_config["cwd"] = str(agent_dir / cwd) - tool_registry.register_mcp_server(server_config) + tool_registry.load_mcp_config(mcp_config_path) llm = None if not mock_mode: diff --git a/.claude/skills/setup-credentials/SKILL.md b/.claude/skills/setup-credentials/SKILL.md index df108890..6dea8d88 100644 --- a/.claude/skills/setup-credentials/SKILL.md +++ b/.claude/skills/setup-credentials/SKILL.md @@ -1,10 +1,10 @@ --- name: setup-credentials -description: Set up and install credentials for an agent. Detects missing credentials from agent config, collects them from the user, and stores them securely in the encrypted credential store at ~/.hive/credentials. +description: Set up and install credentials for an agent. Detects missing credentials from agent config, collects them from the user, and stores them securely in the local encrypted store at ~/.hive/credentials. license: Apache-2.0 metadata: author: hive - version: "2.1" + version: "2.2" type: utility --- @@ -31,48 +31,96 @@ Determine which agent needs credentials. The user will either: Locate the agent's directory under `exports/{agent_name}/`. -### Step 2: Detect Required Credentials +### Step 2: Detect Required Credentials (Bash-First) -Read the agent's configuration to determine which tools and node types it uses: +Use bash commands to determine what the agent needs and what's already configured. This avoids Python import issues and works even when `HIVE_CREDENTIAL_KEY` is not set. -```python -from core.framework.runner import AgentRunner +#### Step 2a: Read Agent Requirements -runner = AgentRunner.load("exports/{agent_name}") -validation = runner.validate() +Extract `required_tools` and node types from the agent config: -# validation.missing_credentials contains env var names -# validation.warnings contains detailed messages with help URLs +```bash +# Get required tools +jq -r '.required_tools[]?' exports/{agent_name}/agent.json 2>/dev/null + +# Get node types from graph nodes +jq -r '.graph.nodes[]?.node_type' exports/{agent_name}/agent.json 2>/dev/null | sort -u ``` -Alternatively, check the credential store directly: +Map the extracted tools and node types to credentials by reading the spec files directly: -```python -from core.framework.credentials import CredentialStore - -# Use encrypted storage (default: ~/.hive/credentials) -store = CredentialStore.with_encrypted_storage() - -# Check what's available -available = store.list_credentials() -print(f"Available credentials: {available}") - -# Check if specific credential exists -if store.is_available("hubspot"): - print("HubSpot credential found") -else: - print("HubSpot credential missing") +```bash +# Read all credential specs — each file defines tools, node_types, env_var, and credential_id +cat tools/src/aden_tools/credentials/llm.py tools/src/aden_tools/credentials/search.py tools/src/aden_tools/credentials/email.py tools/src/aden_tools/credentials/integrations.py ``` -To see all known credential specs (for help URLs and setup instructions): +For each `CredentialSpec`, match its `tools` and `node_types` lists against the agent's required tools and node types. Extract the `env_var`, `credential_id`, and `credential_group` for every match. This is the list of needed credentials. -```python -from aden_tools.credentials import CREDENTIAL_SPECS +#### Step 2b: Check Existing Credential Sources -for name, spec in CREDENTIAL_SPECS.items(): - print(f"{name}: env_var={spec.env_var}, aden={spec.aden_supported}") +For each needed credential, check three sources. A credential is "found" if it exists in ANY of them: + +**1. Encrypted store metadata index** (unencrypted JSON — no decryption key needed): + +```bash +cat ~/.hive/credentials/metadata/index.json 2>/dev/null | jq -r '.credentials | keys[]' ``` +If a credential ID appears in this list, it is stored in the encrypted store. + +**2. Environment variables:** + +```bash +# Check each needed env var, e.g.: +printenv ANTHROPIC_API_KEY > /dev/null 2>&1 && echo "ANTHROPIC_API_KEY: set" || echo "ANTHROPIC_API_KEY: not set" +printenv BRAVE_SEARCH_API_KEY > /dev/null 2>&1 && echo "BRAVE_SEARCH_API_KEY: set" || echo "BRAVE_SEARCH_API_KEY: not set" +``` + +**3. Project `.env` file:** + +```bash +# Check each needed env var, e.g.: +grep -q '^ANTHROPIC_API_KEY=' .env 2>/dev/null && echo "ANTHROPIC_API_KEY: in .env" || echo "ANTHROPIC_API_KEY: not in .env" +grep -q '^BRAVE_SEARCH_API_KEY=' .env 2>/dev/null && echo "BRAVE_SEARCH_API_KEY: in .env" || echo "BRAVE_SEARCH_API_KEY: not in .env" +``` + +#### Step 2c: HIVE_CREDENTIAL_KEY Check + +If any credentials were found in the encrypted store metadata index, verify the encryption key is available. The key is typically persisted to shell config by a previous setup-credentials run. + +Check both the current session AND shell config files: + +```bash +# Check 1: Current session +printenv HIVE_CREDENTIAL_KEY > /dev/null 2>&1 && echo "session: set" || echo "session: not set" + +# Check 2: Shell config files (where setup-credentials persists it) +# Note: check each file individually to avoid non-zero exit when one doesn't exist +for f in ~/.zshrc ~/.bashrc ~/.profile; do [ -f "$f" ] && grep -q 'HIVE_CREDENTIAL_KEY' "$f" && echo "$f"; done +``` + +Decision logic: +- **In current session** — no action needed, credentials in the store are usable +- **In shell config but NOT in current session** — the key is persisted but this shell hasn't sourced it. Run `source ~/.zshrc` (or `~/.bashrc`), then re-check. Credentials in the store are usable after sourcing. +- **Not in session AND not in shell config** — the key was never persisted. Warn the user that credentials in the store cannot be decrypted. Help fix the key situation (recover/re-persist), do NOT re-collect credential values that are already stored. + +#### Step 2d: Compute Missing & Group + +Diff the "needed" credentials against the "found" credentials to get the truly missing list. + +Group related credentials by their `credential_group` field from the spec files. Credentials that share the same non-empty `credential_group` value should be presented as a single setup step rather than asking for each one individually. + +**If nothing is missing and there's no HIVE_CREDENTIAL_KEY issue:** Report all credentials as configured and skip Steps 3-5. Example: + +``` +All required credentials are already configured: + ✓ anthropic (ANTHROPIC_API_KEY) — found in encrypted store + ✓ brave_search (BRAVE_SEARCH_API_KEY) — found in environment +Your agent is ready to run! +``` + +**If credentials are missing:** Continue to Step 3 with only the missing ones. + ### Step 3: Present Auth Options for Each Missing Credential For each missing credential, check what authentication methods are available: @@ -104,7 +152,7 @@ Present the available options using AskUserQuestion: ``` Choose how to configure HUBSPOT_ACCESS_TOKEN: - 1) Aden Authorization Server (Recommended) + 1) Aden Platform (OAuth) (Recommended) Secure OAuth2 flow via integration.adenhq.com - Quick setup with automatic token refresh - No need to manage API keys manually @@ -114,7 +162,7 @@ Choose how to configure HUBSPOT_ACCESS_TOKEN: - Requires creating a HubSpot Private App - Full control over scopes and permissions - 3) Custom Credential Store (Advanced) + 3) Local Credential Setup (Advanced) Programmatic configuration for CI/CD - For automated deployments - Requires manual API calls @@ -122,7 +170,7 @@ Choose how to configure HUBSPOT_ACCESS_TOKEN: ### Step 4: Execute Auth Flow Based on User Choice -#### Option 1: Aden Authorization Server +#### Option 1: Aden Platform (OAuth) This is the recommended flow for supported integrations (HubSpot, etc.). @@ -174,7 +222,7 @@ shell_type = detect_shell() # 'bash', 'zsh', or 'unknown' success, config_path = add_env_var_to_shell_config( "ADEN_API_KEY", user_provided_key, - comment="Aden authorization server API key" + comment="Aden Platform (OAuth) API key" ) if success: @@ -313,7 +361,7 @@ if not result.valid: # 2. Continue anyway (not recommended) ``` -**4.2d. Store in Encrypted Credential Store** +**4.2d. Store in Local Encrypted Store** ```python from core.framework.credentials import CredentialStore, CredentialObject, CredentialKey @@ -340,7 +388,7 @@ store.save_credential(cred) export HUBSPOT_ACCESS_TOKEN="the-value" ``` -#### Option 3: Custom Credential Store (Advanced) +#### Option 3: Local Credential Setup (Advanced) For programmatic/CI/CD setups. @@ -408,10 +456,14 @@ Report the result to the user. Health checks validate credentials by making lightweight API calls: -| Credential | Endpoint | What It Checks | -| -------------- | --------------------------------------- | --------------------------------- | -| `hubspot` | `GET /crm/v3/objects/contacts?limit=1` | Bearer token validity, CRM scopes | -| `brave_search` | `GET /res/v1/web/search?q=test&count=1` | API key validity | +| Credential | Endpoint | What It Checks | +| --------------- | --------------------------------------- | ---------------------------------- | +| `anthropic` | `POST /v1/messages` | API key validity | +| `brave_search` | `GET /res/v1/web/search?q=test&count=1` | API key validity | +| `google_search` | `GET /customsearch/v1?q=test&num=1` | API key + CSE ID validity | +| `github` | `GET /user` | Token validity, user identity | +| `hubspot` | `GET /crm/v3/objects/contacts?limit=1` | Bearer token validity, CRM scopes | +| `resend` | `GET /domains` | API key validity | ```python from aden_tools.credentials import check_credential_health, HealthCheckResult @@ -424,7 +476,7 @@ result: HealthCheckResult = check_credential_health("hubspot", token_value) ## Encryption Key (HIVE_CREDENTIAL_KEY) -The encrypted credential store requires `HIVE_CREDENTIAL_KEY` to encrypt/decrypt credentials. +The local encrypted store requires `HIVE_CREDENTIAL_KEY` to encrypt/decrypt credentials. - If the user doesn't have one, `EncryptedFileStorage` will auto-generate one and log it - The user MUST persist this key (e.g., in `~/.bashrc` or a secrets manager) @@ -443,7 +495,7 @@ If `HIVE_CREDENTIAL_KEY` is not set: - **NEVER** store credentials in plaintext files, git-tracked files, or agent configs - **NEVER** hardcode credentials in source code - **ALWAYS** use `SecretStr` from Pydantic when handling credential values in Python -- **ALWAYS** use the encrypted credential store (`~/.hive/credentials`) for persistence +- **ALWAYS** use the local encrypted store (`~/.hive/credentials`) for persistence - **ALWAYS** run health checks before storing credentials (when possible) - **ALWAYS** verify credentials were stored by re-running validation, not by reading them back - When modifying `~/.bashrc` or `~/.zshrc`, confirm with the user first @@ -456,7 +508,8 @@ All credential specs are defined in `tools/src/aden_tools/credentials/`: | ----------------- | ------------- | --------------------------------------------- | -------------- | | `llm.py` | LLM Providers | `anthropic` | No | | `search.py` | Search Tools | `brave_search`, `google_search`, `google_cse` | No | -| `integrations.py` | Integrations | `hubspot` | Yes | +| `email.py` | Email | `resend` | No | +| `integrations.py` | Integrations | `github`, `hubspot` | No / Yes | **Note:** Additional LLM providers (Cerebras, Groq, OpenAI) are handled by LiteLLM via environment variables (`CEREBRAS_API_KEY`, `GROQ_API_KEY`, `OPENAI_API_KEY`) but are not yet in CREDENTIAL_SPECS. @@ -507,66 +560,90 @@ token = store.get_key("hubspot", "access_token") ## Example Session ``` -User: /setup-credentials for my hubspot-agent +User: /setup-credentials for my research-agent -Agent: Let me check what credentials your hubspot-agent needs. +Agent: Let me check what credentials your research-agent needs. -[Runs validation, finds ANTHROPIC_API_KEY and HUBSPOT_ACCESS_TOKEN missing] +[Reads agent config] +$ jq -r '.required_tools[]?' exports/research-agent/agent.json +web_search +google_search -Agent: Your hubspot-agent requires 2 credentials: +$ jq -r '.graph.nodes[]?.node_type' exports/research-agent/agent.json | sort -u +llm_tool_use - 1. ANTHROPIC_API_KEY - Anthropic API key for LLM calls - 2. HUBSPOT_ACCESS_TOKEN - HubSpot access token +[Maps tools/nodes to credentials using lookup table] +Needed: anthropic, brave_search, google_search, google_cse -Let's set these up one at a time. +[Checks encrypted store metadata index] +$ cat ~/.hive/credentials/metadata/index.json | jq -r '.credentials | keys[]' +anthropic +brave_search ---- Setting up ANTHROPIC_API_KEY --- +[Checks environment variables] +$ printenv ANTHROPIC_API_KEY > /dev/null 2>&1 && echo "set" || echo "not set" +not set +$ printenv BRAVE_SEARCH_API_KEY > /dev/null 2>&1 && echo "set" || echo "not set" +not set +$ printenv GOOGLE_API_KEY > /dev/null 2>&1 && echo "set" || echo "not set" +not set +$ printenv GOOGLE_CSE_ID > /dev/null 2>&1 && echo "set" || echo "not set" +not set -This credential only supports direct API key entry. +[Checks HIVE_CREDENTIAL_KEY since credentials found in store] +$ printenv HIVE_CREDENTIAL_KEY > /dev/null 2>&1 && echo "session: set" || echo "session: not set" +session: not set +$ for f in ~/.zshrc ~/.bashrc ~/.profile; do [ -f "$f" ] && grep -q 'HIVE_CREDENTIAL_KEY' "$f" && echo "$f"; done +/Users/user/.zshrc -[Shows instructions for getting Anthropic API key] -[AskUserQuestion: "Please provide your Anthropic API key:"] -[User provides key] -[Stores in credential store, exports to env] +[Key is in shell config but not current session — sourcing it] +$ source ~/.zshrc ---- Setting up HUBSPOT_ACCESS_TOKEN --- +[Computes missing credentials] +Found: + ✓ anthropic (ANTHROPIC_API_KEY) — in encrypted store + ✓ brave_search (BRAVE_SEARCH_API_KEY) — in encrypted store +Missing: + ✗ google_search (GOOGLE_API_KEY) + ✗ google_cse (GOOGLE_CSE_ID) -[AskUserQuestion with 3 options] +Agent: 2 of 4 required credentials are already configured. Only Google Custom +Search needs setup (2 values as a single group). -Choose how to configure HUBSPOT_ACCESS_TOKEN: - 1) Aden Authorization Server (Recommended) - 2) Direct API Key - 3) Custom Credential Store (Advanced) +--- Setting up Google Custom Search (google_search + google_cse) --- -User: 1 (Aden) +This requires two values that work together. -Agent: Great! Let me check if you have an Aden API key configured... +First, the Google API Key: +1. Go to https://console.cloud.google.com/apis/credentials +2. Create a new project (or select an existing one) +3. Enable the "Custom Search API" from the API Library +4. Go to Credentials > Create Credentials > API Key +5. Copy the generated API key -[Checks for ADEN_API_KEY, not found] -[Opens browser to integration.adenhq.com/setup] - -Agent: I've opened Aden's setup page. Please: - 1. Sign in or create an account - 2. Connect your HubSpot account (OAuth happens on Aden's site) - 3. Copy the API key shown after setup - -[AskUserQuestion: "Please provide your Aden API key:"] +[AskUserQuestion: "Please provide your Google API key:"] [User provides key] -Agent: Would you like me to save this to your shell config (~/.zshrc) for future sessions? +Now, the Custom Search Engine ID: +1. Go to https://programmablesearchengine.google.com/controlpanel/all +2. Click "Add" to create a new search engine +3. Under "What to search", select "Search the entire web" +4. Give your search engine a name +5. Click "Create" +6. Copy the Search Engine ID (cx value) -[User approves] -[Saves to ~/.zshrc and ~/.hive/configuration.json] +[AskUserQuestion: "Please provide your Google CSE ID:"] +[User provides ID] -Agent: Let me sync your credentials from Aden... +[Runs health check with both values - GET /customsearch/v1?q=test&num=1 → 200 OK] +[Stores both in local encrypted store, exports to env] -[Syncs credentials from Aden server - OAuth already done on Aden's side] -[Runs health check] - -Agent: HubSpot credentials validated successfully! +✓ Google Custom Search credentials valid All credentials are now configured: - - ANTHROPIC_API_KEY: Stored in encrypted credential store - - HUBSPOT_ACCESS_TOKEN: Synced from Aden (OAuth completed on Aden) - - Validation passed - your agent is ready to run! + ✓ anthropic (ANTHROPIC_API_KEY) — already in encrypted store + ✓ brave_search (BRAVE_SEARCH_API_KEY) — already in encrypted store + ✓ google_search (GOOGLE_API_KEY) — stored in encrypted store + ✓ google_cse (GOOGLE_CSE_ID) — stored in encrypted store + Your agent is ready to run! ``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0d68fbd0..d972be2b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,9 +1,10 @@ --- name: Bug Report about: Report a bug to help us improve -title: '[Bug]: ' -labels: bug +title: "[Bug]: " +labels: bug, enhancement assignees: '' + --- ## Describe the Bug diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2781dd49..e2d8fa7b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,9 +1,10 @@ --- name: Feature Request about: Suggest a new feature or enhancement -title: '[Feature]: ' +title: "[Feature]: " labels: enhancement assignees: '' + --- ## Problem Statement diff --git a/.github/ISSUE_TEMPLATE/integration-request.md b/.github/ISSUE_TEMPLATE/integration-request.md new file mode 100644 index 00000000..9f74d1ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/integration-request.md @@ -0,0 +1,71 @@ +--- +name: Integration Request +about: Suggest a new integration +title: "[Integration]:" +labels: '' +assignees: '' + +--- + +## Service + + Name and brief description of the service and what it enables agents to do. + + **Description:** [e.g., "API key for Slack Bot" — short one-liner for the credential spec] + + ## Credential Identity + + - **credential_id:** [e.g., `slack`] + - **env_var:** [e.g., `SLACK_BOT_TOKEN`] + - **credential_key:** [e.g., `access_token`, `api_key`, `bot_token`] + + ## Tools + + Tool function names that require this credential: + + - [e.g., `slack_send_message`] + - [e.g., `slack_list_channels`] + + ## Auth Methods + + - **Direct API key supported:** Yes / No + - **Aden OAuth supported:** Yes / No + + If Aden OAuth is supported, describe the OAuth scopes/permissions required. + + ## How to Get the Credential + + Link where users obtain the key/token: + + [e.g., https://api.slack.com/apps] + + Step-by-step instructions: + + 1. Go to ... + 2. Create a ... + 3. Select scopes/permissions: ... + 4. Copy the key/token + + ## Health Check + + A lightweight API call to validate the credential (no writes, no charges). + + - **Endpoint:** [e.g., `https://slack.com/api/auth.test`] + - **Method:** [e.g., `GET` or `POST`] + - **Auth header:** [e.g., `Authorization: Bearer {token}` or `X-Api-Key: {key}`] + - **Parameters (if any):** [e.g., `?limit=1`] + - **200 means:** [e.g., key is valid] + - **401 means:** [e.g., invalid or expired] + - **429 means:** [e.g., rate limited but key is valid] + + ## Credential Group + + Does this require multiple credentials configured together? (e.g., Google Custom Search needs + both an API key and a CSE ID) + + - [ ] No, single credential + - [ ] Yes — list the other credential IDs in the group: + + ## Additional Context + + Links to API docs, rate limits, free tier availability, or anything else relevant. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd8dd077..5ce273fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,8 +79,7 @@ jobs: run: | cd tools uv sync --extra dev - uv pip install --python .venv/bin/python -e ../core - uv run --extra dev pytest tests/ -v + uv run pytest tests/ -v validate: name: Validate Agent Exports diff --git a/.mcp.json b/.mcp.json index 6b49efea..f9b7bee1 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,20 +1,14 @@ { "mcpServers": { "agent-builder": { - "command": ".venv/bin/python", - "args": ["-m", "framework.mcp.agent_builder_server"], - "cwd": "core", - "env": { - "PYTHONPATH": "../tools/src" - } + "command": "uv", + "args": ["run", "-m", "framework.mcp.agent_builder_server"], + "cwd": "core" }, "tools": { - "command": ".venv/bin/python", - "args": ["mcp_server.py", "--stdio"], - "cwd": "tools", - "env": { - "PYTHONPATH": "src:../core" - } + "command": "uv", + "args": ["run", "mcp_server.py", "--stdio"], + "cwd": "tools" } } } diff --git a/README.md b/README.md index 05248729..662f1315 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,13 @@ Aden is a platform for building, deploying, operating, and adapting AI agents: ## Quick Start -### Prerequisites +## Prerequisites -- [Python 3.11+](https://www.python.org/downloads/) for agent development +- Python 3.11+ for agent development - Claude Code or Cursor for utilizing agent skills +> **Note for Windows Users:** It is strongly recommended to use **WSL (Windows Subsystem for Linux)** or **Git Bash** to run this framework. Some core automation scripts may not execute correctly in standard Command Prompt or PowerShell. + ### Installation ```bash diff --git a/core/demos/event_loop_wss_demo.py b/core/demos/event_loop_wss_demo.py index 40bfa488..b3a59f18 100644 --- a/core/demos/event_loop_wss_demo.py +++ b/core/demos/event_loop_wss_demo.py @@ -42,7 +42,7 @@ from framework.credentials.storage import ( # noqa: E402 EncryptedFileStorage, EnvVarStorage, ) -from framework.graph.event_loop_node import EventLoopNode, JudgeVerdict, LoopConfig # noqa: E402 +from framework.graph.event_loop_node import EventLoopNode, LoopConfig # noqa: E402 from framework.graph.node import NodeContext, NodeSpec, SharedMemory # noqa: E402 from framework.llm.litellm import LiteLLMProvider # noqa: E402 from framework.llm.provider import Tool # noqa: E402 @@ -316,47 +316,6 @@ logger.info( ) -# ------------------------------------------------------------------------- -# ChatJudge — keeps the event loop alive between user messages -# ------------------------------------------------------------------------- - - -class ChatJudge: - """Judge that blocks between user messages, keeping the loop alive. - - After the LLM finishes responding, the judge awaits a signal indicating - a new user message has been injected, then returns RETRY to continue. - """ - - def __init__(self, on_ready=None): - self._message_ready = asyncio.Event() - self._shutdown = False - self._on_ready = on_ready # async callback fired when waiting for input - - async def evaluate(self, context: dict) -> JudgeVerdict: - # Notify client that the LLM is done — ready for next input - if self._on_ready: - await self._on_ready() - - # Block until next user message (or shutdown) - self._message_ready.clear() - await self._message_ready.wait() - - if self._shutdown: - return JudgeVerdict(action="ACCEPT") - - return JudgeVerdict(action="RETRY") - - def signal_message(self): - """Unblock the judge — a new user message has been injected.""" - self._message_ready.set() - - def signal_shutdown(self): - """Unblock the judge and let the loop exit cleanly.""" - self._shutdown = True - self._message_ready.set() - - # ------------------------------------------------------------------------- # HTML page (embedded) # ------------------------------------------------------------------------- @@ -565,7 +524,7 @@ connect(); async def handle_ws(websocket): - """Persistent WebSocket: long-lived EventLoopNode kept alive by ChatJudge.""" + """Persistent WebSocket: long-lived EventLoopNode with client_facing blocking.""" global STORE # -- Event forwarding (WebSocket ← EventBus) ---------------------------- @@ -593,15 +552,7 @@ async def handle_ws(websocket): handler=forward_event, ) - # -- Ready callback (tells browser the LLM is done, waiting for input) -- - async def send_ready(): - try: - await websocket.send(json.dumps({"type": "ready"})) - except Exception: - pass - # -- Per-connection state ----------------------------------------------- - judge = ChatJudge(on_ready=send_ready) node = None loop_task = None @@ -613,6 +564,7 @@ async def handle_ws(websocket): name="Chat Assistant", description="A conversational assistant that remembers context across messages", node_type="event_loop", + client_facing=True, system_prompt=( "You are a helpful assistant with access to tools. " "You can search the web, scrape webpages, and query HubSpot CRM. " @@ -621,6 +573,18 @@ async def handle_ws(websocket): ), ) + # -- Ready callback: subscribe to CLIENT_INPUT_REQUESTED on the bus --- + async def on_input_requested(event): + try: + await websocket.send(json.dumps({"type": "ready"})) + except Exception: + pass + + bus.subscribe( + event_types=[EventType.CLIENT_INPUT_REQUESTED], + handler=on_input_requested, + ) + async def start_loop(first_message: str): """Create an EventLoopNode and run it as a background task.""" nonlocal node, loop_task @@ -637,7 +601,6 @@ async def handle_ws(websocket): ) node = EventLoopNode( event_bus=bus, - judge=judge, config=LoopConfig(max_iterations=10_000, max_history_tokens=32_000), conversation_store=STORE, tool_executor=tool_executor, @@ -683,10 +646,11 @@ async def handle_ws(websocket): loop_task = asyncio.create_task(_run()) async def stop_loop(): - """Signal the judge and wait for the loop task to finish.""" + """Signal the node and wait for the loop task to finish.""" nonlocal node, loop_task if loop_task and not loop_task.done(): - judge.signal_shutdown() + if node: + node.signal_shutdown() try: await asyncio.wait_for(loop_task, timeout=5.0) except (TimeoutError, asyncio.CancelledError): @@ -712,8 +676,6 @@ async def handle_ws(websocket): if conv_dir.exists(): shutil.rmtree(conv_dir) STORE = FileConversationStore(conv_dir) - # Reset judge for next session - judge = ChatJudge(on_ready=send_ready) await websocket.send(json.dumps({"type": "cleared"})) logger.info("Conversation cleared") continue @@ -727,10 +689,9 @@ async def handle_ws(websocket): logger.info(f"Starting persistent loop: {topic}") await start_loop(topic) else: - # Subsequent message — inject and unblock the judge + # Subsequent message — inject into the running loop logger.info(f"Injecting message: {topic}") await node.inject_event(topic) - judge.signal_message() except websockets.exceptions.ConnectionClosed: pass diff --git a/core/demos/github_outreach_demo.py b/core/demos/github_outreach_demo.py index f2a29678..b7e33471 100644 --- a/core/demos/github_outreach_demo.py +++ b/core/demos/github_outreach_demo.py @@ -17,7 +17,7 @@ Features demonstrated: - Fan-out / fan-in (Scanner → [Profiler, Scorer] → Extractor) - Feedback edges (Review → Extractor, Approval → Campaign Builder) - Client-facing HITL checkpoints (Intake, Review, Approval) -- Hybrid judges: Pydantic schema validation + custom CheckpointJudge +- SchemaJudge for output validation + native client_facing blocking for HITL - max_node_visits for feedback loop control Usage: @@ -33,7 +33,6 @@ import asyncio import json import logging import os -import re import sys import tempfile from collections.abc import Callable @@ -199,316 +198,30 @@ class CampaignOutput(BaseModel): TOOL_REGISTRY = ToolRegistry() -# ---- GitHub API helpers ---- +# ---- MCP server tools (GitHub, web search, web scrape, email) ---- +# The aden-tools MCP server provides the official tool implementations. +# Tools are auto-discovered via register_mcp_server() and available to +# nodes through their NodeSpec.tools lists. -_GITHUB_API = "https://api.github.com" +_MCP_SERVER_PATH = str(_HIVE_DIR / "tools" / "mcp_server.py") - -def _github_headers() -> dict[str, str]: - """Build GitHub API headers with auth token from credential store or env.""" - token = os.getenv("GITHUB_TOKEN") or CREDENTIALS.get("github") - if not token: - raise RuntimeError( - "GitHub token not configured. " - "Set GITHUB_TOKEN env var or configure via credential store." - ) - return { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", +_mcp_tool_count = TOOL_REGISTRY.register_mcp_server( + { + "name": "aden-tools", + "transport": "stdio", + "command": sys.executable, + "args": [_MCP_SERVER_PATH, "--stdio"], + "description": "Aden tools MCP server (GitHub, web search, email, etc.)", } - - -def _github_get(path: str, params: dict | None = None) -> dict: - """Authenticated GET against the GitHub REST API.""" - try: - resp = httpx.get( - f"{_GITHUB_API}{path}", - headers=_github_headers(), - params=params or {}, - timeout=30.0, - ) - if resp.status_code >= 400: - try: - msg = resp.json().get("message", resp.text) - except Exception: - msg = resp.text - return {"error": f"GitHub API error ({resp.status_code}): {msg}"} - return {"success": True, "data": resp.json()} - except httpx.TimeoutException: - return {"error": "Request timed out"} - except httpx.RequestError as e: - err = str(e) - if "Authorization" in err or "Bearer" in err: - return {"error": "Network error occurred"} - return {"error": f"Network error: {err}"} - - -def _sanitize(value: str) -> str: - """Reject path-traversal characters in URL path segments.""" - if "/" in value or ".." in value: - raise ValueError(f"Invalid parameter: {value!r}") - return value - - -# ---- Real GitHub tools (registered via register_function) ---- - - -def github_get_repo(owner: str, repo: str) -> dict: - """Get repository info including description, stars, forks, language, and topics.""" - return _github_get(f"/repos/{_sanitize(owner)}/{_sanitize(repo)}") - - -def github_list_issues( - owner: str, - repo: str, - state: str = "all", - page: int = 1, - limit: int = 100, -) -> dict: - """List issues for a repository. Each issue includes author login, title, labels. - - Use page parameter to paginate through results (page 1, 2, 3, ...). - Each page returns up to `limit` items (max 100). - """ - limit = int(limit) - page = max(1, int(page)) - return _github_get( - f"/repos/{_sanitize(owner)}/{_sanitize(repo)}/issues", - {"state": state, "per_page": min(limit, 100), "page": page}, - ) - - -def github_list_pull_requests( - owner: str, - repo: str, - state: str = "all", - page: int = 1, - limit: int = 100, -) -> dict: - """List pull requests for a repository. Each PR includes author login and title. - - Use page parameter to paginate through results (page 1, 2, 3, ...). - Each page returns up to `limit` items (max 100). - """ - limit = int(limit) - page = max(1, int(page)) - return _github_get( - f"/repos/{_sanitize(owner)}/{_sanitize(repo)}/pulls", - {"state": state, "per_page": min(limit, 100), "page": page}, - ) - - -def github_list_stargazers( - owner: str, - repo: str, - page: int = 1, - limit: int = 100, -) -> dict: - """List users who starred a repository. Each entry has a login username. - - Use page parameter to paginate through results (page 1, 2, 3, ...). - Each page returns up to `limit` items (max 100). - """ - limit = int(limit) - page = max(1, int(page)) - return _github_get( - f"/repos/{_sanitize(owner)}/{_sanitize(repo)}/stargazers", - {"per_page": min(limit, 100), "page": page}, - ) - - -def github_get_user_profile(username: str) -> dict: - """Get a GitHub user's public profile: name, bio, company, location, email.""" - return _github_get(f"/users/{_sanitize(username)}") - - -def github_list_user_repos( - username: str, - sort: str = "updated", - limit: int = 10, -) -> dict: - """List public repos for a GitHub user to understand their tech stack.""" - limit = int(limit) - return _github_get( - f"/users/{_sanitize(username)}/repos", - {"type": "public", "sort": sort, "per_page": min(limit, 100)}, - ) - - -TOOL_REGISTRY.register_function(github_get_repo) -TOOL_REGISTRY.register_function(github_list_issues) -TOOL_REGISTRY.register_function(github_list_pull_requests) -TOOL_REGISTRY.register_function(github_list_stargazers) -TOOL_REGISTRY.register_function(github_get_user_profile) -TOOL_REGISTRY.register_function(github_list_user_repos) - - -# ---- Web search + scrape tools (contact enrichment) ---- - -_BROWSER_UA = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/131.0.0.0 Safari/537.36" ) +logger.info("Registered %d tools from MCP server", _mcp_tool_count) -def _search_credentials() -> dict[str, str | None]: - """Get search API credentials from credential store or env.""" - return { - "brave_api_key": (CREDENTIALS.get("brave_search") or os.getenv("BRAVE_SEARCH_API_KEY")), - "google_api_key": (CREDENTIALS.get("google_search") or os.getenv("GOOGLE_API_KEY")), - "google_cse_id": (CREDENTIALS.get("google_cse") or os.getenv("GOOGLE_CSE_ID")), - } +# ---- Demo-specific tools (context management for large data) ---- +# Agents use these to write large intermediate results to disk and pass +# filenames between nodes, keeping the LLM conversation context small. - -def web_search(query: str, num_results: int = 5) -> dict: - """Search the web for information using Brave or Google.""" - num_results = int(num_results) - if not query or len(query) > 500: - return {"error": "Query must be 1-500 characters"} - - creds = _search_credentials() - - try: - # Try Brave first - if creds["brave_api_key"]: - resp = httpx.get( - "https://api.search.brave.com/res/v1/web/search", - params={ - "q": query, - "count": min(num_results, 20), - }, - headers={ - "X-Subscription-Token": creds["brave_api_key"], - "Accept": "application/json", - }, - timeout=30.0, - ) - if resp.status_code == 200: - data = resp.json() - web = data.get("web", {}) - results = [ - { - "title": r.get("title", ""), - "url": r.get("url", ""), - "snippet": r.get("description", ""), - } - for r in web.get("results", [])[:num_results] - ] - return { - "query": query, - "results": results, - "total": len(results), - "provider": "brave", - } - return { - "error": f"Brave search failed: HTTP {resp.status_code}", - } - - # Fall back to Google - if creds["google_api_key"] and creds["google_cse_id"]: - resp = httpx.get( - "https://www.googleapis.com/customsearch/v1", - params={ - "key": creds["google_api_key"], - "cx": creds["google_cse_id"], - "q": query, - "num": min(num_results, 10), - }, - timeout=30.0, - ) - if resp.status_code == 200: - data = resp.json() - results = [ - { - "title": r.get("title", ""), - "url": r.get("link", ""), - "snippet": r.get("snippet", ""), - } - for r in data.get("items", [])[:num_results] - ] - return { - "query": query, - "results": results, - "total": len(results), - "provider": "google", - } - return { - "error": (f"Google search failed: HTTP {resp.status_code}"), - } - - return { - "error": ( - "No search credentials configured. " - "Set BRAVE_SEARCH_API_KEY or " - "GOOGLE_API_KEY+GOOGLE_CSE_ID" - ), - } - - except httpx.TimeoutException: - return {"error": "Search request timed out"} - except httpx.RequestError as e: - return {"error": f"Network error: {e}"} - - -def web_scrape(url: str, max_length: int = 10000) -> dict: - """Scrape and extract text content from a webpage URL.""" - max_length = int(max_length) - if not url.startswith(("http://", "https://")): - url = "https://" + url - - try: - resp = httpx.get( - url, - headers={"User-Agent": _BROWSER_UA}, - timeout=30.0, - follow_redirects=True, - ) - if resp.status_code != 200: - return { - "error": f"HTTP {resp.status_code}: Failed to fetch URL", - } - - html = resp.text - - # Extract title before stripping tags - title_m = re.search( - r"]*>(.*?)", - html, - re.IGNORECASE | re.DOTALL, - ) - title = title_m.group(1).strip() if title_m else "" - - # Strip script, style, nav, footer, header contents - html = re.sub( - r"<(script|style|nav|footer|header)[^>]*>.*?", - "", - html, - flags=re.DOTALL | re.IGNORECASE, - ) - # Strip remaining HTML tags - text = re.sub(r"<[^>]+>", " ", html) - # Normalize whitespace - text = re.sub(r"\s+", " ", text).strip() - - if len(text) > max_length: - text = text[:max_length] + "..." - - return { - "url": url, - "title": title, - "content": text, - "length": len(text), - } - - except httpx.TimeoutException: - return {"error": "Request timed out"} - except httpx.RequestError as e: - return {"error": f"Network error: {e}"} - - -# ---- Mock tools (no direct API equivalent) ---- +_DATA_DIR = STORE_DIR / "data" def load_campaign_template() -> dict: @@ -527,13 +240,6 @@ def load_campaign_template() -> dict: } -# ---- File ops tools (context management for large data) ---- -# Agents use these to write large intermediate results to disk and pass -# filenames between nodes, keeping the LLM conversation context small. - -_DATA_DIR = STORE_DIR / "data" - - def save_data(filename: str, data: str) -> dict: """Save data to a file for later retrieval by this or downstream nodes. @@ -584,10 +290,22 @@ def load_data(filename: str, offset: int = 0, limit: int = 50) -> dict: if not path.exists(): return {"error": f"File not found: {filename}"} content = path.read_text(encoding="utf-8") - all_lines = content.split("\n") - total = len(all_lines) size_bytes = len(content.encode("utf-8")) + # If content is a single long line, try to pretty-print JSON so + # line-based pagination actually works. Handles spillover files + # written before the pretty-print fix in _truncate_tool_result(). + all_lines = content.split("\n") + if len(all_lines) <= 2 and size_bytes > 500: + try: + parsed = json.loads(content) + content = json.dumps(parsed, indent=2, ensure_ascii=False) + all_lines = content.split("\n") + except (json.JSONDecodeError, TypeError, ValueError): + pass # Not JSON — keep original lines + + total = len(all_lines) + start = min(offset, total) end = min(start + limit, total) sliced = all_lines[start:end] @@ -624,8 +342,6 @@ def list_data_files() -> dict: return {"files": files} -TOOL_REGISTRY.register_function(web_search) -TOOL_REGISTRY.register_function(web_scrape) TOOL_REGISTRY.register_function(load_campaign_template) TOOL_REGISTRY.register_function(save_data) TOOL_REGISTRY.register_function(load_data) @@ -659,7 +375,11 @@ NODE_SPECS = { "4. **min_leads** — Minimum number of leads to collect (a number). " "Ask the operator: 'How many leads do you need at minimum?' " "If they say something vague like 'as many as possible', default to 50.\n\n" - "Once you have all four, call set_output for each key with the values provided. " + "CRITICAL: Once you have all four, you MUST call the set_output tool for EACH " + "key. Call set_output(key='repo_url', value='...'), then " + "set_output(key='project_url', value='...'), etc. for all four keys.\n" + "Do NOT just say 'ready to proceed' or 'handing off' — the pipeline ONLY " + "advances when you make actual set_output tool calls.\n" "For min_leads, set it as a plain number string (e.g., '100').\n" "Be conversational but efficient. Ask for missing information if the operator " "doesn't provide everything at once." @@ -677,6 +397,7 @@ NODE_SPECS = { "github_list_issues", "github_list_pull_requests", "github_list_stargazers", + "execute_command_tool", "save_data", "load_data", "list_data_files", @@ -712,6 +433,12 @@ NODE_SPECS = { "If a user appears in multiple categories, use the highest-value type " "(contributor > issue_author > stargazer).\n\n" "Respect the scan_config for any limits or filtering preferences.\n\n" + "SHELL TOOL:\n" + "You have execute_command_tool for running shell commands. Use it for file " + "management tasks like merging JSON files, deduplicating data, or processing " + "saved data with jq/python one-liners. Example:\n" + " execute_command_tool(command='cat data/file.json | python3 -c \"import sys,json; " + "d=json.load(sys.stdin); print(len(d))\"')\n\n" "CONTEXT MANAGEMENT:\n" "Format the result as a JSON array of objects with 'username' and 'user_type'.\n" "Use save_data('github_users.json', ) to write the list to a file.\n" @@ -729,7 +456,8 @@ NODE_SPECS = { output_keys=["user_profiles"], tools=[ "github_get_user_profile", - "github_list_user_repos", + "github_list_repos", + "execute_command_tool", "load_data", "save_data", ], @@ -737,11 +465,16 @@ NODE_SPECS = { "You are a GitHub Profiler agent. Your input 'github_users' is a filename.\n\n" "WORKFLOW:\n" "1. Use load_data to read the user list from the input filename\n" - "2. For each user, call github_get_user_profile and github_list_user_repos\n" + "2. For each user, call github_get_user_profile to get their profile, and " + "github_list_repos(username=) to list their public repos\n" "3. Compile profiles into a JSON array with: username, name, bio, company, " "languages (from repos)\n" "4. Use save_data('user_profiles.json', ) to write results to a file\n" "5. Call set_output(key='user_profiles', value='user_profiles.json')\n\n" + "SHELL TOOL:\n" + "You have execute_command_tool for running shell commands. Use it for file " + "management tasks like filtering JSON, extracting fields, or merging data. " + "Example: execute_command_tool(command='python3 -c \"import json; ...')\n\n" "CONTEXT MANAGEMENT:\n" "Start by loading the user list file. For large lists, use load_data with " "offset and limit to page through users in batches " @@ -778,7 +511,14 @@ NODE_SPECS = { node_type="event_loop", input_keys=["user_profiles", "relevance_scores"], output_keys=["contact_list"], - tools=["web_search", "web_scrape", "load_data", "save_data"], + tools=[ + "github_get_user_emails", + "web_search", + "web_scrape", + "execute_command_tool", + "load_data", + "save_data", + ], max_node_visits=3, system_prompt=( "You are a Contact Extractor agent. Your inputs 'user_profiles' and " @@ -788,13 +528,21 @@ NODE_SPECS = { "use offset and limit to page through them incrementally " "(e.g. load_data('file.json', offset=0, limit=30))\n" "2. For each user with relevance score >= 0.3, enrich their contact info:\n" - " - Use web_search to find contact details ('{username} github email', etc.)\n" + " - Use github_get_user_emails(username) to find emails from their " + "public commits and profile (this is the BEST source for GitHub emails)\n" + " - If no email found, try web_search ('{username} github email')\n" " - If results include personal sites, use web_scrape to extract details\n" " - Look for: email addresses, Twitter/X handles, LinkedIn profiles\n" "3. Compile a JSON array of contacts with: username, name, email, twitter, " "relevance_score\n" "4. Use save_data('contact_list.json', ) to write results\n" "5. Call set_output(key='contact_list', value='contact_list.json')\n\n" + "SHELL TOOL:\n" + "You have execute_command_tool for running shell commands. Use it for file " + "management tasks like merging profiles and scores, deduplicating contacts, " + "or batch-processing data. Example:\n" + " execute_command_tool(command='python3 -c \"import json; profiles=json.load(" + 'open(\\"data/user_profiles.json\\")); print(len(profiles))"\')\n\n' "Include all users who have at least one contact method." ), ), @@ -830,25 +578,48 @@ NODE_SPECS = { "campaign_builder": NodeSpec( id="campaign_builder", name="Campaign Builder", - description="Build personalized outreach emails from approved contacts", + description="Iteratively build personalized outreach emails in batches", node_type="event_loop", + client_facing=True, input_keys=["approved_contacts", "project_url"], output_keys=["draft_emails"], + nullable_output_keys=["draft_emails"], tools=["load_campaign_template", "load_data", "save_data"], - max_node_visits=3, + max_node_visits=5, system_prompt=( "You are the Campaign Builder agent. Your input 'approved_contacts' is a " "filename and 'project_url' is a URL string.\n\n" - "WORKFLOW:\n" - "1. Use load_data to read the approved contacts from the file\n" - "2. Load the campaign template using load_campaign_template\n" - "3. For each approved contact, customize the email:\n" - " - Fill in their name and relevant details\n" - " - Add a personalized hook based on their profile/interests\n" - "4. Format as a JSON array of email objects with: recipient, subject, body\n" - "5. Use save_data('draft_emails.json', ) to write the drafts\n" - "6. Call set_output(key='draft_emails', value='draft_emails.json')\n\n" - "Make each email feel personal and relevant." + "ITERATIVE BATCH WORKFLOW:\n" + "You build draft emails in batches of up to 10 contacts at a time, " + "presenting each batch to the operator for review before continuing.\n\n" + "STEP 1 — SETUP:\n" + "- Use load_data to read the approved contacts from the file\n" + "- Load the campaign template using load_campaign_template\n" + "- Count total contacts and plan batches of up to 10\n\n" + "STEP 2 — DRAFT A BATCH (max 10 emails):\n" + "- For each contact in the current batch, write a personalized email:\n" + " * Fill in their name and relevant details from their profile\n" + " * Add a personalized hook based on their interests/contributions\n" + " * Format: {recipient, subject, body}\n" + "- Present the drafted emails to the operator clearly, showing:\n" + " * Batch number (e.g. 'Batch 1 of 4')\n" + " * Each email with recipient, subject, and body preview\n" + " * How many contacts remain\n\n" + "STEP 3 — ASK THE OPERATOR:\n" + "After presenting each batch, ask:\n" + " 'Create more drafts for the next batch, or submit all drafts " + "for outbound email?'\n\n" + " IF 'create more' → go back to STEP 2 for the next batch of contacts\n" + " IF 'submit' → go to STEP 4\n\n" + "STEP 4 — FINALIZE:\n" + "- Combine ALL drafted batches into a single JSON array\n" + "- Use save_data('draft_emails.json', ) to write them\n" + "- Call set_output(key='draft_emails', value='draft_emails.json')\n\n" + "RULES:\n" + "- Never draft more than 10 emails at once — always pause for operator input\n" + "- Keep a running total of emails drafted across batches\n" + "- Make each email feel personal and relevant\n" + "- Do NOT call set_output until the operator says to submit" ), ), "approval": NodeSpec( @@ -1016,6 +787,12 @@ def _send_email_via_resend( if not api_key: return {"error": "Resend API key not configured"} + # Testing override: redirect all recipients to a single address + override_to = os.getenv("EMAIL_OVERRIDE_TO") + if override_to: + subject = f"[TEST -> {to}] {subject}" + to = override_to + try: resp = httpx.post( "https://api.resend.com/emails", @@ -1118,7 +895,7 @@ def send_emails(approved_emails: str = "") -> str: # ========================================================================= -# Judges (Hybrid: SchemaJudge + CheckpointJudge) +# Judges (SchemaJudge for output validation) # ========================================================================= @@ -1160,9 +937,13 @@ class SchemaJudge: if value is None: continue if isinstance(value, str): - try: - parsed[key] = json.loads(value) - except json.JSONDecodeError: + stripped = value.strip() + if stripped and stripped[0] in ("{", "["): + try: + parsed[key] = json.loads(value) + except json.JSONDecodeError: + parsed[key] = value + else: parsed[key] = value else: parsed[key] = value @@ -1207,91 +988,6 @@ class SchemaJudge: return JudgeVerdict(action="ACCEPT") -class CheckpointJudge: - """Judge for client-facing HITL nodes. - - Combines ChatJudge blocking pattern with optional schema validation: - - Blocks between user messages (via asyncio.Event) - - When LLM sets any output key → validates against Pydantic model (if provided) - - ACCEPT on valid output, RETRY on invalid or no output yet - """ - - def __init__( - self, - event_bus: EventBus, - node_id: str, - output_model: type[BaseModel] | None = None, - ): - self._bus = event_bus - self._node_id = node_id - self._model = output_model - self._message_ready = asyncio.Event() - self._shutdown = False - - async def evaluate(self, context: dict) -> JudgeVerdict: - accumulator = context.get("output_accumulator", {}) - - # Check if any output key has a real (non-None, non-empty) value - def _is_set(v: Any) -> bool: - if v is None: - return False - if isinstance(v, str) and len(v.strip()) == 0: - return False - return True - - set_keys = [k for k, v in accumulator.items() if _is_set(v)] - - if set_keys: - # Validate against schema if provided - if self._model is not None: - try: - parsed = {} - for k, v in accumulator.items(): - if not _is_set(v): - continue - if isinstance(v, str): - try: - parsed[k] = json.loads(v) - except json.JSONDecodeError: - parsed[k] = v - else: - parsed[k] = v - self._model.model_validate(parsed) - except (ValidationError, json.JSONDecodeError) as e: - return JudgeVerdict( - action="RETRY", - feedback=f"Output validation failed: {e}. Fix and re-set.", - ) - return JudgeVerdict(action="ACCEPT") - - if self._shutdown: - return JudgeVerdict(action="ACCEPT") - - # Emit awaiting_input event for UI - await self._bus.publish( - AgentEvent( - type=EventType.CUSTOM, - stream_id="pipeline", - node_id=self._node_id, - data={"custom_type": "awaiting_input", "node_id": self._node_id}, - ) - ) - - # Block until next user message - self._message_ready.clear() - await self._message_ready.wait() - return JudgeVerdict(action="RETRY") - - def signal_message(self): - """Unblock the judge — a new user message has been injected.""" - self._message_ready.set() - - def signal_shutdown(self): - """Unblock the judge and let the loop exit cleanly.""" - self._shutdown = True - self._message_ready.set() - - # ========================================================================= # HTML Page (embedded) # ========================================================================= @@ -1369,7 +1065,7 @@ HTML_PAGE = """ .node-scorer .node-tag { color: #bc8cff; } .node-extractor .node-tag { color: #d29922; } .node-review .node-tag { color: #58a6ff; } - .node-campaign_builder .node-tag { color: #d29922; } + .node-campaign_builder .node-tag { color: #58a6ff; } .node-approval .node-tag { color: #58a6ff; } .node-sender .node-tag { color: #3fb950; } .result-banner { @@ -1467,7 +1163,7 @@ HTML_PAGE = """ .activity-card.node-profiler .card-label { color: #bc8cff; } .activity-card.node-scorer .card-label { color: #bc8cff; } .activity-card.node-extractor .card-label { color: #d29922; } - .activity-card.node-campaign_builder .card-label { color: #d29922; } + .activity-card.node-campaign_builder .card-label { color: #58a6ff; } @@ -1796,7 +1492,7 @@ const NODE_SPECS = { intake: {client_facing: true}, scanner: {client_facing: false}, profiler: {client_facing: false}, scorer: {client_facing: false}, extractor: {client_facing: false}, review: {client_facing: true}, - campaign_builder: {client_facing: false}, approval: {client_facing: true}, + campaign_builder: {client_facing: true}, approval: {client_facing: true}, sender: {client_facing: false} }; @@ -2013,7 +1709,6 @@ async def _run_pipeline(websocket, initial_message: str): bus = EventBus() # State for routing user messages to active client-facing node - active_checkpoint: CheckpointJudge | None = None active_node: EventLoopNode | None = None pending_messages: list[str] = [] @@ -2021,14 +1716,17 @@ async def _run_pipeline(websocket, initial_message: str): pipeline_config: dict[str, Any] = {"min_leads": 50} # default - # --- Build judges (hybrid approach — Option C) --- + # --- Build judges --- + # Client-facing nodes (intake, review, approval) have client_facing=True + # in their NodeSpec — EventLoopNode blocks for user input natively. + # Only nodes that need output schema validation get a judge. - # Client-facing: CheckpointJudge (blocks for user, optional schema) - checkpoint_judges: dict[str, CheckpointJudge] = { - "intake": CheckpointJudge(bus, "intake", output_model=IntakeOutput), - "review": CheckpointJudge(bus, "review"), - "approval": CheckpointJudge(bus, "approval"), + # Intake needs schema validation for its structured outputs + client_judges: dict[str, SchemaJudge] = { + "intake": SchemaJudge(IntakeOutput), } + # review & approval: no judge — implicit judge checks output keys, + # and client_facing blocking is handled by the node itself. # Internal: SchemaJudge (validates output structure) schema_judges: dict[str, SchemaJudge] = { @@ -2040,10 +1738,11 @@ async def _run_pipeline(websocket, initial_message: str): "profiler": SchemaJudge(ProfilerOutput), "scorer": SchemaJudge(ScorerOutput), "extractor": SchemaJudge(ExtractorOutput), - "campaign_builder": SchemaJudge(CampaignOutput), + # campaign_builder is now client_facing — implicit judge + native + # blocking handle termination (same pattern as review/approval). } - all_judges: dict = {**checkpoint_judges, **schema_judges} + all_judges: dict = {**client_judges, **schema_judges} # --- Build EventLoopNode for each event_loop node --- @@ -2095,6 +1794,10 @@ async def _run_pipeline(websocket, initial_message: str): if event.type == EventType.CUSTOM and "custom_type" in event.data: payload["type"] = event.data["custom_type"] + # Remap CLIENT_INPUT_REQUESTED to awaiting_input for JS compatibility + if event.type == EventType.CLIENT_INPUT_REQUESTED: + payload["type"] = "awaiting_input" + await websocket.send(json.dumps(payload)) except Exception: pass @@ -2108,6 +1811,7 @@ async def _run_pipeline(websocket, initial_message: str): EventType.TOOL_CALL_STARTED, EventType.TOOL_CALL_COMPLETED, EventType.CLIENT_OUTPUT_DELTA, + EventType.CLIENT_INPUT_REQUESTED, EventType.NODE_STALLED, EventType.CUSTOM, ], @@ -2115,26 +1819,25 @@ async def _run_pipeline(websocket, initial_message: str): ) # --- Track active client-facing node for message routing --- + # EventLoopNode publishes CLIENT_INPUT_REQUESTED when a client_facing + # node blocks for user input (native blocking, no judge needed). + + CLIENT_FACING_NODES = {"intake", "review", "approval"} async def on_awaiting_input(event: AgentEvent): - nonlocal active_checkpoint, active_node - if event.type != EventType.CUSTOM: + nonlocal active_node + nid = event.node_id or "" + if nid not in CLIENT_FACING_NODES: return - if event.data.get("custom_type") != "awaiting_input": - return - nid = event.data.get("node_id", "") - if nid in checkpoint_judges: - active_checkpoint = checkpoint_judges[nid] - active_node = nodes.get(nid) - logger.info("Active HITL node: %s", nid) - # Deliver any pending messages - while pending_messages: - msg_text = pending_messages.pop(0) - if active_node: - await active_node.inject_event(msg_text) - active_checkpoint.signal_message() + active_node = nodes.get(nid) + logger.info("Active HITL node: %s", nid) + # Deliver any pending messages + while pending_messages: + msg_text = pending_messages.pop(0) + if active_node: + await active_node.inject_event(msg_text) - bus.subscribe(event_types=[EventType.CUSTOM], handler=on_awaiting_input) + bus.subscribe(event_types=[EventType.CLIENT_INPUT_REQUESTED], handler=on_awaiting_input) # --- Capture min_leads from Intake's set_output tool call --- @@ -2192,9 +1895,8 @@ async def _run_pipeline(websocket, initial_message: str): ) ) - if active_node and active_checkpoint: + if active_node: await active_node.inject_event(text) - active_checkpoint.signal_message() else: pending_messages.append(text) except websockets.exceptions.ConnectionClosed: @@ -2205,10 +1907,11 @@ async def _run_pipeline(websocket, initial_message: str): # --- Wait for pipeline to complete --- try: - result = await asyncio.wait_for(pipeline_task, timeout=600) + result = await asyncio.wait_for(pipeline_task, timeout=1800) except TimeoutError: - for judge in checkpoint_judges.values(): - judge.signal_shutdown() + for nid in CLIENT_FACING_NODES: + if nid in nodes: + nodes[nid].signal_shutdown() reader_task.cancel() await websocket.send( json.dumps({"type": "error", "message": "Pipeline timed out (10 min)"}) diff --git a/core/framework/graph/event_loop_node.py b/core/framework/graph/event_loop_node.py index f3ca0cb5..bb395663 100644 --- a/core/framework/graph/event_loop_node.py +++ b/core/framework/graph/event_loop_node.py @@ -143,13 +143,21 @@ class EventLoopNode(NodeProtocol): Lifecycle: 1. Try to restore from durable state (crash recovery) 2. If no prior state, init from NodeSpec.system_prompt + input_keys - 3. Loop: drain injection queue -> stream LLM -> execute tools -> judge + 3. Loop: drain injection queue -> stream LLM -> execute tools + -> if client_facing + no tools: block for user input (inject_event) + -> if not client_facing or tools present: judge evaluates (each add_* and set_output writes through to store immediately) 4. Publish events to EventBus at each stage 5. Write cursor after each iteration - 6. Terminate when judge returns ACCEPT (or max iterations) + 6. Terminate when judge returns ACCEPT, shutdown signaled, or max iterations 7. Build output dict from OutputAccumulator + Client-facing blocking: When ``client_facing=True`` and the LLM produces + text without tool calls (a natural conversational turn), the node blocks + via ``_await_user_input()`` until ``inject_event()`` or ``signal_shutdown()`` + is called. This separates blocking (node concern) from output evaluation + (judge concern). + Always returns NodeResult with retryable=False semantics. The executor must NOT retry event loop nodes -- retry is handled internally by the judge (RETRY action continues the loop). See WP-7 enforcement. @@ -169,6 +177,9 @@ class EventLoopNode(NodeProtocol): self._tool_executor = tool_executor self._conversation_store = conversation_store self._injection_queue: asyncio.Queue[str] = asyncio.Queue() + # Client-facing input blocking state + self._input_ready = asyncio.Event() + self._shutdown = False def validate_input(self, ctx: NodeContext) -> list[str]: """Validate hard requirements only. @@ -221,6 +232,15 @@ class EventLoopNode(NodeProtocol): if set_output_tool: tools.append(set_output_tool) + logger.info( + "[%s] Tools available (%d): %s | client_facing=%s | judge=%s", + node_id, + len(tools), + [t.name for t in tools], + ctx.node_spec.client_facing, + type(self._judge).__name__ if self._judge else "None", + ) + # 4. Publish loop started await self._publish_loop_started(stream_id, node_id) @@ -250,9 +270,24 @@ class EventLoopNode(NodeProtocol): await self._compact_tiered(ctx, conversation, accumulator) # 6e. Run single LLM turn + logger.info( + "[%s] iter=%d: running LLM turn (msgs=%d)", + node_id, + iteration, + len(conversation.messages), + ) assistant_text, tool_results_list, turn_tokens = await self._run_single_turn( ctx, conversation, tools, iteration, accumulator ) + logger.info( + "[%s] iter=%d: LLM done — text=%d chars, tool_calls=%d, tokens=%s, accumulator=%s", + node_id, + iteration, + len(assistant_text), + len(tool_results_list), + turn_tokens, + {k: ("set" if v is not None else "None") for k, v in accumulator.to_dict().items()}, + ) total_input_tokens += turn_tokens.get("input", 0) total_output_tokens += turn_tokens.get("output", 0) @@ -286,12 +321,85 @@ class EventLoopNode(NodeProtocol): # 6g. Write cursor checkpoint await self._write_cursor(ctx, conversation, accumulator, iteration) - # 6h. Judge evaluation + # 6h. Client-facing input wait + logger.info( + "[%s] iter=%d: 6h check — client_facing=%s, tool_results=%d", + node_id, + iteration, + ctx.node_spec.client_facing, + len(tool_results_list), + ) + if ctx.node_spec.client_facing and not tool_results_list: + # LLM finished speaking (no tool calls) on a client-facing node. + # This is a conversational turn boundary: block for user input + # instead of running the judge. + if self._shutdown: + await self._publish_loop_completed(stream_id, node_id, iteration + 1) + latency_ms = int((time.time() - start_time) * 1000) + return NodeResult( + success=True, + output=accumulator.to_dict(), + tokens_used=total_input_tokens + total_output_tokens, + latency_ms=latency_ms, + ) + + logger.info("[%s] iter=%d: blocking for user input...", node_id, iteration) + got_input = await self._await_user_input(ctx) + logger.info("[%s] iter=%d: unblocked, got_input=%s", node_id, iteration, got_input) + if not got_input: + # Shutdown signaled during wait + await self._publish_loop_completed(stream_id, node_id, iteration + 1) + latency_ms = int((time.time() - start_time) * 1000) + return NodeResult( + success=True, + output=accumulator.to_dict(), + tokens_used=total_input_tokens + total_output_tokens, + latency_ms=latency_ms, + ) + + # Clear stall detection — user input resets the conversation + recent_responses.clear() + + # For nodes with an explicit judge, fall through to judge + # evaluation so the LLM gets structured feedback about missing + # outputs (e.g. "Missing output keys: [...]"). Without this, + # the LLM may generate text like "Ready to proceed!" without + # ever calling set_output, and the judge feedback never reaches it. + # + # For nodes without a judge (HITL review/approval with all- + # nullable keys), keep conversing UNLESS the LLM has already + # set an output — in that case fall through to the implicit + # judge which will ACCEPT and terminate the node. + if self._judge is None: + has_outputs = accumulator and any( + v is not None for v in accumulator.to_dict().values() + ) + if not has_outputs: + logger.info( + "[%s] iter=%d: no judge, no outputs, continuing", + node_id, + iteration, + ) + continue + logger.info( + "[%s] iter=%d: no judge, outputs set — implicit judge", + node_id, + iteration, + ) + else: + logger.info( + "[%s] iter=%d: has judge, falling through to 6i", + node_id, + iteration, + ) + + # 6i. Judge evaluation should_judge = ( (iteration + 1) % self._config.judge_every_n_turns == 0 or not tool_results_list # no tool calls = natural stop ) + logger.info("[%s] iter=%d: 6i should_judge=%s", node_id, iteration, should_judge) if should_judge: verdict = await self._evaluate( ctx, @@ -301,6 +409,14 @@ class EventLoopNode(NodeProtocol): tool_results_list, iteration, ) + fb_preview = (verdict.feedback or "")[:200] + logger.info( + "[%s] iter=%d: judge verdict=%s feedback=%r", + node_id, + iteration, + verdict.action, + fb_preview, + ) if verdict.action == "ACCEPT": # Check for missing output keys @@ -312,6 +428,12 @@ class EventLoopNode(NodeProtocol): f"Missing required output keys: {missing}. " "Use set_output to provide them." ) + logger.info( + "[%s] iter=%d: ACCEPT but missing keys %s", + node_id, + iteration, + missing, + ) await conversation.add_user_message(hint) continue @@ -360,8 +482,38 @@ class EventLoopNode(NodeProtocol): The content becomes a user message prepended to the next iteration. Thread-safe via asyncio.Queue. + Also unblocks _await_user_input() if the node is waiting. """ await self._injection_queue.put(content) + self._input_ready.set() + + def signal_shutdown(self) -> None: + """Signal the node to exit its loop cleanly. + + Unblocks any pending _await_user_input() call and causes + the loop to exit on the next check. + """ + self._shutdown = True + self._input_ready.set() + + async def _await_user_input(self, ctx: NodeContext) -> bool: + """Block until user input arrives or shutdown is signaled. + + Called when a client_facing node produces text without tool calls — + a natural conversational turn boundary. + + Returns True if input arrived, False if shutdown was signaled. + """ + if self._event_bus: + await self._event_bus.emit_client_input_requested( + stream_id=ctx.node_id, + node_id=ctx.node_id, + prompt="", + ) + + self._input_ready.clear() + await self._input_ready.wait() + return not self._shutdown # ------------------------------------------------------------------- # Single LLM turn with caller-managed tool orchestration @@ -426,6 +578,12 @@ class EventLoopNode(NodeProtocol): logger.warning(f"Recoverable stream error: {event.error}") final_text = accumulated_text + logger.info( + "[%s] LLM response: text=%r tool_calls=%s", + node_id, + accumulated_text[:300] if accumulated_text else "(empty)", + [tc.tool_name for tc in tool_calls] if tool_calls else "[]", + ) # Record assistant message (write-through via conversation store) tc_dicts = None @@ -467,6 +625,12 @@ class EventLoopNode(NodeProtocol): ) # Handle set_output synthetic tool + logger.info( + "[%s] tool_call: %s(%s)", + node_id, + tc.tool_name, + json.dumps(tc.tool_input)[:200], + ) if tc.tool_name == "set_output": result = self._handle_set_output(tc.tool_input, ctx.node_spec.output_keys) result = ToolResult( @@ -834,7 +998,18 @@ class EventLoopNode(NodeProtocol): # Use tool_use_id for uniqueness, sanitise for filesystem safe_id = result.tool_use_id.replace("/", "_")[:60] filename = f"tool_{tool_name}_{safe_id}.txt" - (spill_path / filename).write_text(result.content, encoding="utf-8") + + # Pretty-print JSON content so load_data's line-based + # pagination works correctly. Compact JSON (no newlines) + # would produce a single line that defeats pagination. + write_content = result.content + try: + parsed = json.loads(result.content) + write_content = json.dumps(parsed, indent=2, ensure_ascii=False) + except (json.JSONDecodeError, TypeError, ValueError): + pass # Not JSON — write as-is + + (spill_path / filename).write_text(write_content, encoding="utf-8") truncated = ( f"[Result from {tool_name}: {len(result.content)} chars — " @@ -1129,6 +1304,10 @@ class EventLoopNode(NodeProtocol): while not self._injection_queue.empty(): try: content = self._injection_queue.get_nowait() + logger.info( + "[drain] injected message: %s", + content[:200] if content else "(empty)", + ) await conversation.add_user_message(f"[External event]: {content}") count += 1 except asyncio.QueueEmpty: diff --git a/core/framework/graph/executor.py b/core/framework/graph/executor.py index 74891060..f1e972da 100644 --- a/core/framework/graph/executor.py +++ b/core/framework/graph/executor.py @@ -261,7 +261,7 @@ class GraphExecutor: total_latency = 0 node_retry_counts: dict[str, int] = {} # Track retries per node node_visit_counts: dict[str, int] = {} # Track visits for feedback loops - is_retrying = False # Flag to avoid counting retries as feedback-loop visits + _is_retry = False # True when looping back for a retry (not a new visit) # Determine entry point (may differ if resuming) current_node_id = graph.get_entry_point(session_state) @@ -291,12 +291,11 @@ class GraphExecutor: raise RuntimeError(f"Node not found: {current_node_id}") # Enforce max_node_visits (feedback/callback edge support) - # Don't count retries as new visits — retries have their own limit. - if not is_retrying: - node_visit_counts[current_node_id] = ( - node_visit_counts.get(current_node_id, 0) + 1 - ) - is_retrying = False + # Don't increment visit count on retries — retries are not new visits + if not _is_retry: + cnt = node_visit_counts.get(current_node_id, 0) + 1 + node_visit_counts[current_node_id] = cnt + _is_retry = False max_visits = getattr(node_spec, "max_node_visits", 1) if max_visits > 0 and node_visit_counts[current_node_id] > max_visits: self.logger.warning( @@ -457,7 +456,7 @@ class GraphExecutor: self.logger.info( f" ↻ Retrying ({node_retry_counts[current_node_id]}/{max_retries})..." ) - is_retrying = True + _is_retry = True continue else: # Max retries exceeded - fail the execution diff --git a/core/framework/llm/anthropic.py b/core/framework/llm/anthropic.py index 0bdfd11e..4afaca38 100644 --- a/core/framework/llm/anthropic.py +++ b/core/framework/llm/anthropic.py @@ -18,7 +18,7 @@ def _get_api_key_from_credential_store() -> str | None: try: from aden_tools.credentials import CredentialStoreAdapter - creds = CredentialStoreAdapter.with_env_storage() + creds = CredentialStoreAdapter.default() if creds.is_available("anthropic"): return creds.get("anthropic") except ImportError: diff --git a/core/framework/mcp/agent_builder_server.py b/core/framework/mcp/agent_builder_server.py index 3b616889..5dc3c13a 100644 --- a/core/framework/mcp/agent_builder_server.py +++ b/core/framework/mcp/agent_builder_server.py @@ -3417,7 +3417,7 @@ def store_credential( display_name: Annotated[str, "Human-readable name (e.g., 'HubSpot Access Token')"] = "", ) -> str: """ - Store a credential securely in the encrypted credential store at ~/.hive/credentials. + Store a credential securely in the local encrypted store at ~/.hive/credentials. Uses Fernet encryption (AES-128-CBC + HMAC). Requires HIVE_CREDENTIAL_KEY env var. """ @@ -3459,7 +3459,7 @@ def store_credential( @mcp.tool() def list_stored_credentials() -> str: """ - List all credentials currently stored in the encrypted credential store. + List all credentials currently stored in the local encrypted store. Returns credential IDs and metadata (never returns secret values). """ @@ -3499,7 +3499,7 @@ def delete_stored_credential( credential_name: Annotated[str, "Logical credential name to delete (e.g., 'hubspot')"], ) -> str: """ - Delete a credential from the encrypted credential store. + Delete a credential from the local encrypted store. """ try: store = _get_credential_store() diff --git a/core/framework/runner/runner.py b/core/framework/runner/runner.py index 78726efb..d28f5bc7 100644 --- a/core/framework/runner/runner.py +++ b/core/framework/runner/runner.py @@ -543,30 +543,8 @@ class AgentRunner: return self._tool_registry.register_mcp_server(server_config) def _load_mcp_servers_from_config(self, config_path: Path) -> None: - """ - Load and register MCP servers from a configuration file. - - Args: - config_path: Path to mcp_servers.json file - """ - try: - with open(config_path) as f: - config = json.load(f) - - servers = config.get("servers", []) - for server_config in servers: - # Resolve relative cwd paths against the agent directory - if "cwd" in server_config and server_config["cwd"]: - cwd_path = Path(server_config["cwd"]) - if not cwd_path.is_absolute(): - server_config["cwd"] = str((self.agent_path / cwd_path).resolve()) - try: - self._tool_registry.register_mcp_server(server_config) - except Exception as e: - server_name = server_config.get("name", "unknown") - logger.warning(f"Failed to register MCP server '{server_name}': {e}") - except Exception as e: - logger.warning(f"Failed to load MCP servers config from {config_path}: {e}") + """Load and register MCP servers from a configuration file.""" + self._tool_registry.load_mcp_config(config_path) def set_approval_callback(self, callback: Callable) -> None: """ diff --git a/core/framework/runner/tool_registry.py b/core/framework/runner/tool_registry.py index 878f3436..e82374d8 100644 --- a/core/framework/runner/tool_registry.py +++ b/core/framework/runner/tool_registry.py @@ -262,6 +262,34 @@ class ToolRegistry: """ self._session_context.update(context) + def load_mcp_config(self, config_path: Path) -> None: + """ + Load and register MCP servers from a config file. + + Resolves relative ``cwd`` paths against the config file's parent + directory so callers never need to handle path resolution themselves. + + Args: + config_path: Path to an ``mcp_servers.json`` file. + """ + try: + with open(config_path) as f: + config = json.load(f) + except Exception as e: + logger.warning(f"Failed to load MCP config from {config_path}: {e}") + return + + base_dir = config_path.parent + for server_config in config.get("servers", []): + cwd = server_config.get("cwd") + if cwd and not Path(cwd).is_absolute(): + server_config["cwd"] = str((base_dir / cwd).resolve()) + try: + self.register_mcp_server(server_config) + except Exception as e: + name = server_config.get("name", "unknown") + logger.warning(f"Failed to register MCP server '{name}': {e}") + def register_mcp_server( self, server_config: dict[str, Any], @@ -310,9 +338,6 @@ class ToolRegistry: # Register each tool count = 0 for mcp_tool in client.list_tools(): - # Capture the full set of params this tool accepts (before schema stripping) - accepted_params = set(mcp_tool.input_schema.get("properties", {}).keys()) - # Convert MCP tool to framework Tool (strips context params from LLM schema) tool = self._convert_mcp_tool_to_framework_tool(mcp_tool) @@ -320,19 +345,18 @@ class ToolRegistry: def make_mcp_executor( client_ref: MCPClient, tool_name: str, - registry_ref: "ToolRegistry", - tool_params: set, + registry_ref, + tool_params: set[str], ): def executor(inputs: dict) -> Any: try: - # Only inject context params the tool actually accepts + # Only inject session context params the tool accepts filtered_context = { k: v for k, v in registry_ref._session_context.items() if k in tool_params } - # Context overrides inputs so framework values always win - merged_inputs = {**inputs, **filtered_context} + merged_inputs = {**filtered_context, **inputs} result = client_ref.call_tool(tool_name, merged_inputs) # MCP tools return content array, extract the result if isinstance(result, list) and len(result) > 0: @@ -346,10 +370,11 @@ class ToolRegistry: return executor + tool_params = set(mcp_tool.input_schema.get("properties", {}).keys()) self.register( mcp_tool.name, tool, - make_mcp_executor(client, mcp_tool.name, self, accepted_params), + make_mcp_executor(client, mcp_tool.name, self, tool_params), ) count += 1 diff --git a/core/framework/testing/prompts.py b/core/framework/testing/prompts.py index e42994f8..3bbe8898 100644 --- a/core/framework/testing/prompts.py +++ b/core/framework/testing/prompts.py @@ -24,7 +24,7 @@ def _get_api_key(): # 1. Try CredentialStoreAdapter for Anthropic try: from aden_tools.credentials import CredentialStoreAdapter - creds = CredentialStoreAdapter.with_env_storage() + creds = CredentialStoreAdapter.default() if creds.is_available("anthropic"): return creds.get("anthropic") except (ImportError, KeyError): @@ -57,7 +57,7 @@ def _get_api_key(): """Get API key from CredentialStoreAdapter or environment.""" try: from aden_tools.credentials import CredentialStoreAdapter - creds = CredentialStoreAdapter.with_env_storage() + creds = CredentialStoreAdapter.default() if creds.is_available("anthropic"): return creds.get("anthropic") except (ImportError, KeyError): diff --git a/core/pyproject.toml b/core/pyproject.toml index 7f079e8c..78a022cc 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pytest>=8.0", "pytest-asyncio>=0.23", "pytest-xdist>=3.0", + "tools", ] [project.optional-dependencies] @@ -22,6 +23,9 @@ tui = ["textual>=0.75.0"] [project.scripts] hive = "framework.cli:main" +[tool.uv.sources] +tools = { workspace = true } + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/core/tests/test_concurrent_storage.py b/core/tests/test_concurrent_storage.py index 5a842838..5d8d0d43 100644 --- a/core/tests/test_concurrent_storage.py +++ b/core/tests/test_concurrent_storage.py @@ -86,8 +86,11 @@ async def test_batched_write_cache_consistency(tmp_path: Path): "Cache should not be updated before batch is flushed" ) - # Wait for batch to flush - await asyncio.sleep(0.1) + # Wait for batch to flush (poll instead of fixed sleep for CI reliability) + for _ in range(500): # 500 * 0.01s = 5s max + if cache_key in storage._cache: + break + await asyncio.sleep(0.01) # After batch flush, cache should contain the run assert cache_key in storage._cache, "Cache should be updated after batch flush" diff --git a/core/tests/test_event_loop_integration.py b/core/tests/test_event_loop_integration.py index b8af9739..34633a73 100644 --- a/core/tests/test_event_loop_integration.py +++ b/core/tests/test_event_loop_integration.py @@ -8,6 +8,7 @@ Set HIVE_TEST_LLM_MODEL= to override the real model. from __future__ import annotations +import asyncio import os from collections.abc import AsyncIterator, Callable from dataclasses import dataclass @@ -950,7 +951,15 @@ async def test_client_facing_node_streams_output(): event_bus=bus, config=LoopConfig(max_iterations=5), ) + + # client_facing + text-only blocks for user input; use shutdown to unblock + async def auto_shutdown(): + await asyncio.sleep(0.05) + node.signal_shutdown() + + task = asyncio.create_task(auto_shutdown()) result = await node.execute(ctx) + await task assert result.success diff --git a/core/tests/test_event_loop_node.py b/core/tests/test_event_loop_node.py index 5b65f316..e4486cb8 100644 --- a/core/tests/test_event_loop_node.py +++ b/core/tests/test_event_loop_node.py @@ -446,12 +446,172 @@ class TestEventBusLifecycle: ctx = build_ctx(runtime, spec, memory, llm) node = EventLoopNode(event_bus=bus, config=LoopConfig(max_iterations=5)) + + # client_facing + text-only blocks for user input; use shutdown to unblock + async def auto_shutdown(): + await asyncio.sleep(0.05) + node.signal_shutdown() + + task = asyncio.create_task(auto_shutdown()) await node.execute(ctx) + await task assert EventType.CLIENT_OUTPUT_DELTA in received_types assert EventType.LLM_TEXT_DELTA not in received_types +# =========================================================================== +# Client-facing blocking +# =========================================================================== + + +class TestClientFacingBlocking: + """Tests for native client_facing input blocking in EventLoopNode.""" + + @pytest.fixture + def client_spec(self): + return NodeSpec( + id="chat", + name="Chat", + description="chat node", + node_type="event_loop", + output_keys=[], + client_facing=True, + ) + + @pytest.mark.asyncio + async def test_client_facing_blocks_on_text(self, runtime, memory, client_spec): + """client_facing + text-only response blocks until inject_event.""" + llm = MockStreamingLLM( + scenarios=[ + text_scenario("Hello!"), + text_scenario("Got your message."), + ] + ) + bus = EventBus() + node = EventLoopNode(event_bus=bus, config=LoopConfig(max_iterations=5)) + ctx = build_ctx(runtime, client_spec, memory, llm) + + async def user_responds(): + await asyncio.sleep(0.05) + await node.inject_event("I need help") + await asyncio.sleep(0.05) + node.signal_shutdown() + + user_task = asyncio.create_task(user_responds()) + result = await node.execute(ctx) + await user_task + + assert result.success is True + # LLM should have been called at least twice (first response + after inject) + assert llm._call_index >= 2 + + @pytest.mark.asyncio + async def test_client_facing_does_not_block_on_tools(self, runtime, memory): + """client_facing + tool calls should NOT block — judge evaluates normally.""" + spec = NodeSpec( + id="chat", + name="Chat", + description="chat node", + node_type="event_loop", + output_keys=["result"], + client_facing=True, + ) + # Scenario 1: LLM calls set_output (tool call present → no blocking, judge RETRYs) + # Scenario 2: LLM produces text (implicit judge sees output key set → ACCEPT) + # But scenario 2 is text-only on client_facing → would block. + # So we need shutdown to handle that case. + llm = MockStreamingLLM( + scenarios=[ + tool_call_scenario("set_output", {"key": "result", "value": "done"}), + text_scenario("All set!"), + ] + ) + node = EventLoopNode(config=LoopConfig(max_iterations=5)) + ctx = build_ctx(runtime, spec, memory, llm) + + # After set_output, implicit judge RETRYs (tool calls present). + # Next turn: text-only on client_facing → blocks. + # But implicit judge should ACCEPT first (output key is set, no tools). + # Actually, client_facing check happens BEFORE judge, so it blocks. + # Use shutdown as safety net. + async def auto_shutdown(): + await asyncio.sleep(0.1) + node.signal_shutdown() + + task = asyncio.create_task(auto_shutdown()) + result = await node.execute(ctx) + await task + + assert result.success is True + assert result.output["result"] == "done" + + @pytest.mark.asyncio + async def test_non_client_facing_unchanged(self, runtime, memory): + """client_facing=False should not block — existing behavior.""" + spec = NodeSpec( + id="internal", + name="Internal", + description="internal node", + node_type="event_loop", + output_keys=[], + ) + llm = MockStreamingLLM(scenarios=[text_scenario("thinking...")]) + node = EventLoopNode(config=LoopConfig(max_iterations=2)) + ctx = build_ctx(runtime, spec, memory, llm) + + # Should complete without blocking (implicit judge ACCEPTs on no tools + no keys) + result = await node.execute(ctx) + assert result is not None + + @pytest.mark.asyncio + async def test_signal_shutdown_unblocks(self, runtime, memory, client_spec): + """signal_shutdown should unblock a waiting client_facing node.""" + llm = MockStreamingLLM(scenarios=[text_scenario("Waiting...")]) + bus = EventBus() + node = EventLoopNode(event_bus=bus, config=LoopConfig(max_iterations=10)) + ctx = build_ctx(runtime, client_spec, memory, llm) + + async def shutdown_after_delay(): + await asyncio.sleep(0.05) + node.signal_shutdown() + + task = asyncio.create_task(shutdown_after_delay()) + result = await node.execute(ctx) + await task + + assert result.success is True + + @pytest.mark.asyncio + async def test_client_input_requested_event_published(self, runtime, memory, client_spec): + """CLIENT_INPUT_REQUESTED should be published when blocking.""" + llm = MockStreamingLLM(scenarios=[text_scenario("Hello!")]) + bus = EventBus() + received = [] + + async def capture(e): + received.append(e) + + bus.subscribe( + event_types=[EventType.CLIENT_INPUT_REQUESTED], + handler=capture, + ) + + node = EventLoopNode(event_bus=bus, config=LoopConfig(max_iterations=5)) + ctx = build_ctx(runtime, client_spec, memory, llm) + + async def shutdown(): + await asyncio.sleep(0.05) + node.signal_shutdown() + + task = asyncio.create_task(shutdown()) + await node.execute(ctx) + await task + + assert len(received) >= 1 + assert received[0].type == EventType.CLIENT_INPUT_REQUESTED + + # =========================================================================== # Tool execution # =========================================================================== diff --git a/docs/credential-store-design.md b/docs/credential-store-design.md index 7e605cd4..54525249 100644 --- a/docs/credential-store-design.md +++ b/docs/credential-store-design.md @@ -1757,7 +1757,7 @@ tools/src/aden_tools/credentials/ ### Manual Testing -- [ ] Create encrypted credential store +- [ ] Create local encrypted store - [ ] Save and load multi-key credentials - [ ] Verify template resolution in tool headers - [ ] Test OAuth2 token refresh diff --git a/examples/templates/marketing_agent/config.py b/examples/templates/marketing_agent/config.py index 2c426230..66b595fc 100644 --- a/examples/templates/marketing_agent/config.py +++ b/examples/templates/marketing_agent/config.py @@ -17,7 +17,9 @@ class AgentMetadata: version: str = "0.1.0" description: str = "Multi-channel marketing content generator" author: str = "" - tags: list[str] = field(default_factory=lambda: ["marketing", "content", "template"]) + tags: list[str] = field( + default_factory=lambda: ["marketing", "content", "template"] + ) default_config = RuntimeConfig() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..aee25b99 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.uv.workspace] +members = ["core", "tools"] diff --git a/quickstart.sh b/quickstart.sh index 9d6279c4..6d751fd7 100755 --- a/quickstart.sh +++ b/quickstart.sh @@ -183,41 +183,26 @@ echo "" echo -e "${DIM}This may take a minute...${NC}" echo "" -# Install framework package from core/ -echo -n " Installing framework... " -cd "$SCRIPT_DIR/core" +# Install all workspace packages (core + tools) from workspace root +echo -n " Installing workspace packages... " +cd "$SCRIPT_DIR" if [ -f "pyproject.toml" ]; then if uv sync > /dev/null 2>&1; then - echo -e "${GREEN} ✓ framework package installed${NC}" + echo -e "${GREEN} ✓ workspace packages installed${NC}" else - echo -e "${YELLOW} ⚠ framework installation had issues (may be OK)${NC}" - fi -else - echo -e "${RED}failed (no pyproject.toml)${NC}" - exit 1 -fi - -# Install aden_tools package from tools/ -echo -n " Installing tools... " -cd "$SCRIPT_DIR/tools" - -if [ -f "pyproject.toml" ]; then - if uv sync > /dev/null 2>&1; then - echo -e "${GREEN} ✓ aden_tools package installed${NC}" - else - echo -e "${RED} ✗ aden_tools installation failed${NC}" + echo -e "${RED} ✗ workspace installation failed${NC}" exit 1 fi else - echo -e "${RED}failed${NC}" + echo -e "${RED}failed (no root pyproject.toml)${NC}" exit 1 fi # Install Playwright browser echo -n " Installing Playwright browser... " -if $PYTHON_CMD -c "import playwright" > /dev/null 2>&1; then - if $PYTHON_CMD -m playwright install chromium > /dev/null 2>&1; then +if uv run python -c "import playwright" > /dev/null 2>&1; then + if uv run python -m playwright install chromium > /dev/null 2>&1; then echo -e "${GREEN}ok${NC}" else echo -e "${YELLOW}⏭${NC}" @@ -236,33 +221,6 @@ echo "" # ============================================================ echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Step 3: Configuring LLM provider...${NC}" -# Install MCP dependencies (in tools venv) -echo " Installing MCP dependencies..." -TOOLS_PYTHON="$SCRIPT_DIR/tools/.venv/bin/python" -uv pip install --python "$TOOLS_PYTHON" mcp fastmcp > /dev/null 2>&1 -echo -e "${GREEN} ✓ MCP dependencies installed${NC}" - -# Fix openai version compatibility (in tools venv) -TOOLS_PYTHON="$SCRIPT_DIR/tools/.venv/bin/python" -OPENAI_VERSION=$($TOOLS_PYTHON -c "import openai; print(openai.__version__)" 2>/dev/null || echo "not_installed") -if [ "$OPENAI_VERSION" = "not_installed" ]; then - echo " Installing openai package..." - uv pip install --python "$TOOLS_PYTHON" "openai>=1.0.0" > /dev/null 2>&1 - echo -e "${GREEN} ✓ openai installed${NC}" -elif [[ "$OPENAI_VERSION" =~ ^0\. ]]; then - echo " Upgrading openai to 1.x+ for litellm compatibility..." - uv pip install --python "$TOOLS_PYTHON" --upgrade "openai>=1.0.0" > /dev/null 2>&1 - echo -e "${GREEN} ✓ openai upgraded${NC}" -else - echo -e "${GREEN} ✓ openai $OPENAI_VERSION is compatible${NC}" -fi - -# Install click for CLI (in tools venv) -TOOLS_PYTHON="$SCRIPT_DIR/tools/.venv/bin/python" -uv pip install --python "$TOOLS_PYTHON" click > /dev/null 2>&1 -echo -e "${GREEN} ✓ click installed${NC}" - -cd "$SCRIPT_DIR" echo "" # ============================================================ @@ -274,42 +232,28 @@ echo "" IMPORT_ERRORS=0 -# Test imports using their respective venvs -CORE_PYTHON="$SCRIPT_DIR/core/.venv/bin/python" -TOOLS_PYTHON="$SCRIPT_DIR/tools/.venv/bin/python" - -# Test framework import (from core venv) -if [ -f "$CORE_PYTHON" ] && $CORE_PYTHON -c "import framework" > /dev/null 2>&1; then +# 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 -# Test aden_tools import (from tools venv) -if [ -f "$TOOLS_PYTHON" ] && $TOOLS_PYTHON -c "import aden_tools" > /dev/null 2>&1; then +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 -# Test litellm import (from core venv) -if [ -f "$CORE_PYTHON" ] && $CORE_PYTHON -c "import litellm" > /dev/null 2>&1; then - echo -e "${GREEN} ✓ litellm imports OK (core)${NC}" +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 in core (may be OK)${NC}" + echo -e "${YELLOW} ⚠ litellm import issues (may be OK)${NC}" fi -# Test litellm import (from tools venv) -if [ -f "$TOOLS_PYTHON" ] && $TOOLS_PYTHON -c "import litellm" > /dev/null 2>&1; then - echo -e "${GREEN} ✓ litellm imports OK (tools)${NC}" -else - echo -e "${YELLOW} ⚠ litellm import issues in tools (may be OK)${NC}" -fi - -# Test MCP server module (from core venv) -if [ -f "$CORE_PYTHON" ] && $CORE_PYTHON -c "from framework.mcp import agent_builder_server" > /dev/null 2>&1; then +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}" else echo -e "${RED} ✗ MCP server module failed${NC}" @@ -647,7 +591,7 @@ ERRORS=0 # Test imports echo -n " ⬡ framework... " -if $CORE_PYTHON -c "import framework" > /dev/null 2>&1; then +if uv run python -c "import framework" > /dev/null 2>&1; then echo -e "${GREEN}ok${NC}" else echo -e "${RED}failed${NC}" @@ -655,7 +599,7 @@ else fi echo -n " ⬡ aden_tools... " -if $TOOLS_PYTHON -c "import aden_tools" > /dev/null 2>&1; then +if uv run python -c "import aden_tools" > /dev/null 2>&1; then echo -e "${GREEN}ok${NC}" else echo -e "${RED}failed${NC}" @@ -663,7 +607,7 @@ else fi echo -n " ⬡ litellm... " -if $CORE_PYTHON -c "import litellm" > /dev/null 2>&1 || $TOOLS_PYTHON -c "import litellm" > /dev/null 2>&1; then +if uv run python -c "import litellm" > /dev/null 2>&1; then echo -e "${GREEN}ok${NC}" else echo -e "${YELLOW}--${NC}" diff --git a/tools/mcp_server.py b/tools/mcp_server.py index 43677134..af8db89b 100644 --- a/tools/mcp_server.py +++ b/tools/mcp_server.py @@ -68,22 +68,7 @@ from starlette.responses import PlainTextResponse # noqa: E402 from aden_tools.credentials import CredentialError, CredentialStoreAdapter # noqa: E402 from aden_tools.tools import register_all_tools # noqa: E402 -# Create credential store with access to both env vars AND encrypted store -# This allows using Aden-synced credentials from ~/.hive/credentials -# Only use encrypted storage if HIVE_CREDENTIAL_KEY is configured; -# otherwise fall back to env-only to avoid generating a throwaway key. -if os.environ.get("HIVE_CREDENTIAL_KEY"): - try: - from framework.credentials import CredentialStore - - store = CredentialStore.with_encrypted_storage() # ~/.hive/credentials - credentials = CredentialStoreAdapter(store) - logger.info("Using CredentialStoreAdapter with encrypted storage") - except Exception as e: - credentials = CredentialStoreAdapter.with_env_storage() - logger.warning(f"Falling back to env-only CredentialStoreAdapter: {e}") -else: - credentials = CredentialStoreAdapter.with_env_storage() +credentials = CredentialStoreAdapter.default() # Tier 1: Validate startup-required credentials (if any) try: diff --git a/tools/pyproject.toml b/tools/pyproject.toml index 2ec3a751..8c12504a 100644 --- a/tools/pyproject.toml +++ b/tools/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "playwright-stealth>=1.0.5", "litellm>=1.81.0", "resend>=2.0.0", + "framework", ] [project.optional-dependencies] @@ -54,6 +55,9 @@ all = [ "duckdb>=1.0.0", ] +[tool.uv.sources] +framework = { workspace = true } + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/tools/src/aden_tools/__init__.py b/tools/src/aden_tools/__init__.py index 4827b6b3..7234da4e 100644 --- a/tools/src/aden_tools/__init__.py +++ b/tools/src/aden_tools/__init__.py @@ -10,7 +10,7 @@ Usage: from aden_tools.credentials import CredentialStoreAdapter mcp = FastMCP("my-server") - credentials = CredentialStoreAdapter.with_env_storage() + credentials = CredentialStoreAdapter.default() register_all_tools(mcp, credentials=credentials) """ diff --git a/tools/src/aden_tools/credentials/__init__.py b/tools/src/aden_tools/credentials/__init__.py index c3323d8e..8fa8ce4c 100644 --- a/tools/src/aden_tools/credentials/__init__.py +++ b/tools/src/aden_tools/credentials/__init__.py @@ -15,8 +15,8 @@ Usage: store = CredentialStore.with_encrypted_storage() # defaults to ~/.hive/credentials credentials = CredentialStoreAdapter(store) - # With env vars only (simple setup) - credentials = CredentialStoreAdapter.with_env_storage() + # With composite storage (encrypted primary + env fallback) + credentials = CredentialStoreAdapter.default() # In agent runner (validate at agent load time) credentials.validate_for_tools(["web_search", "file_read"]) diff --git a/tools/src/aden_tools/credentials/base.py b/tools/src/aden_tools/credentials/base.py index 06f5118c..377a5689 100644 --- a/tools/src/aden_tools/credentials/base.py +++ b/tools/src/aden_tools/credentials/base.py @@ -70,6 +70,9 @@ class CredentialSpec: credential_key: str = "access_token" """Key name within the credential (e.g., 'access_token', 'api_key')""" + credential_group: str = "" + """Group name for credentials that must be configured together (e.g., 'google_custom_search')""" + class CredentialError(Exception): """Raised when required credentials are missing.""" diff --git a/tools/src/aden_tools/credentials/email.py b/tools/src/aden_tools/credentials/email.py index 0c34ade6..8d373095 100644 --- a/tools/src/aden_tools/credentials/email.py +++ b/tools/src/aden_tools/credentials/email.py @@ -15,5 +15,22 @@ EMAIL_CREDENTIALS = { startup_required=False, help_url="https://resend.com/api-keys", description="API key for Resend email service", + # Auth method support + direct_api_key_supported=True, + api_key_instructions="""To get a Resend API key: +1. Go to https://resend.com and create an account (or sign in) +2. Navigate to API Keys in the dashboard +3. Click "Create API Key" +4. Give it a name (e.g., "Hive Agent") and choose permissions: + - "Sending access" is sufficient for most use cases + - "Full access" if you also need to manage domains +5. Copy the API key (starts with re_) +6. Store it securely - you won't be able to see it again! +7. Note: You'll also need to verify a domain to send emails from custom addresses""", + # Health check configuration + health_check_endpoint="https://api.resend.com/domains", + # Credential store mapping + credential_id="resend", + credential_key="api_key", ), } diff --git a/tools/src/aden_tools/credentials/health_check.py b/tools/src/aden_tools/credentials/health_check.py index 457c29b2..e49560bd 100644 --- a/tools/src/aden_tools/credentials/health_check.py +++ b/tools/src/aden_tools/credentials/health_check.py @@ -231,10 +231,213 @@ class GoogleSearchHealthChecker: ) +class AnthropicHealthChecker: + """Health checker for Anthropic API credentials.""" + + ENDPOINT = "https://api.anthropic.com/v1/messages" + TIMEOUT = 10.0 + + def check(self, api_key: str) -> HealthCheckResult: + """ + Validate Anthropic API key without consuming tokens. + + Sends a deliberately invalid request (empty messages) to the messages endpoint. + A 401 means invalid key; 400 (bad request) means the key authenticated + but the payload was rejected — confirming the key is valid without + generating any tokens. 429 (rate limited) also indicates a valid key. + """ + try: + with httpx.Client(timeout=self.TIMEOUT) as client: + response = client.post( + self.ENDPOINT, + headers={ + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + # Empty messages triggers 400 (not 200), so no tokens are consumed. + json={ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1, + "messages": [], + }, + ) + + if response.status_code == 200: + return HealthCheckResult( + valid=True, + message="Anthropic API key valid", + ) + elif response.status_code == 401: + return HealthCheckResult( + valid=False, + message="Anthropic API key is invalid", + details={"status_code": 401}, + ) + elif response.status_code == 429: + # Rate limited but key is valid + return HealthCheckResult( + valid=True, + message="Anthropic API key valid (rate limited)", + details={"status_code": 429, "rate_limited": True}, + ) + elif response.status_code == 400: + # Bad request but key authenticated - key is valid + return HealthCheckResult( + valid=True, + message="Anthropic API key valid", + details={"status_code": 400}, + ) + else: + return HealthCheckResult( + valid=False, + message=f"Anthropic API returned status {response.status_code}", + details={"status_code": response.status_code}, + ) + except httpx.TimeoutException: + return HealthCheckResult( + valid=False, + message="Anthropic API request timed out", + details={"error": "timeout"}, + ) + except httpx.RequestError as e: + return HealthCheckResult( + valid=False, + message=f"Failed to connect to Anthropic API: {e}", + details={"error": str(e)}, + ) + + +class GitHubHealthChecker: + """Health checker for GitHub Personal Access Token.""" + + ENDPOINT = "https://api.github.com/user" + TIMEOUT = 10.0 + + def check(self, access_token: str) -> HealthCheckResult: + """ + Validate GitHub token by fetching the authenticated user. + + Returns the authenticated username on success. + """ + try: + with httpx.Client(timeout=self.TIMEOUT) as client: + response = client.get( + self.ENDPOINT, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + + if response.status_code == 200: + data = response.json() + username = data.get("login", "unknown") + return HealthCheckResult( + valid=True, + message=f"GitHub token valid (authenticated as {username})", + details={"username": username}, + ) + elif response.status_code == 401: + return HealthCheckResult( + valid=False, + message="GitHub token is invalid or expired", + details={"status_code": 401}, + ) + elif response.status_code == 403: + return HealthCheckResult( + valid=False, + message="GitHub token lacks required permissions", + details={"status_code": 403}, + ) + else: + return HealthCheckResult( + valid=False, + message=f"GitHub API returned status {response.status_code}", + details={"status_code": response.status_code}, + ) + except httpx.TimeoutException: + return HealthCheckResult( + valid=False, + message="GitHub API request timed out", + details={"error": "timeout"}, + ) + except httpx.RequestError as e: + return HealthCheckResult( + valid=False, + message=f"Failed to connect to GitHub API: {e}", + details={"error": str(e)}, + ) + + +class ResendHealthChecker: + """Health checker for Resend API credentials.""" + + ENDPOINT = "https://api.resend.com/domains" + TIMEOUT = 10.0 + + def check(self, api_key: str) -> HealthCheckResult: + """ + Validate Resend API key by listing domains. + + A successful response confirms the key is valid. + """ + try: + with httpx.Client(timeout=self.TIMEOUT) as client: + response = client.get( + self.ENDPOINT, + headers={ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + }, + ) + + if response.status_code == 200: + return HealthCheckResult( + valid=True, + message="Resend API key valid", + ) + elif response.status_code == 401: + return HealthCheckResult( + valid=False, + message="Resend API key is invalid", + details={"status_code": 401}, + ) + elif response.status_code == 403: + return HealthCheckResult( + valid=False, + message="Resend API key lacks required permissions", + details={"status_code": 403}, + ) + else: + return HealthCheckResult( + valid=False, + message=f"Resend API returned status {response.status_code}", + details={"status_code": response.status_code}, + ) + except httpx.TimeoutException: + return HealthCheckResult( + valid=False, + message="Resend API request timed out", + details={"error": "timeout"}, + ) + except httpx.RequestError as e: + return HealthCheckResult( + valid=False, + message=f"Failed to connect to Resend API: {e}", + details={"error": str(e)}, + ) + + # Registry of health checkers HEALTH_CHECKERS: dict[str, CredentialHealthChecker] = { "hubspot": HubSpotHealthChecker(), "brave_search": BraveSearchHealthChecker(), + "google_search": GoogleSearchHealthChecker(), + "anthropic": AnthropicHealthChecker(), + "github": GitHubHealthChecker(), + "resend": ResendHealthChecker(), } diff --git a/tools/src/aden_tools/credentials/llm.py b/tools/src/aden_tools/credentials/llm.py index d5e38d81..267eb4c5 100644 --- a/tools/src/aden_tools/credentials/llm.py +++ b/tools/src/aden_tools/credentials/llm.py @@ -15,6 +15,21 @@ LLM_CREDENTIALS = { startup_required=False, # MCP server doesn't need LLM credentials help_url="https://console.anthropic.com/settings/keys", description="API key for Anthropic Claude models", + # Auth method support + direct_api_key_supported=True, + api_key_instructions="""To get an Anthropic API key: +1. Go to https://console.anthropic.com/settings/keys +2. Sign in or create an Anthropic account +3. Click "Create Key" +4. Give your key a descriptive name (e.g., "Hive Agent") +5. Copy the API key (starts with sk-ant-) +6. Store it securely - you won't be able to see the full key again!""", + # Health check configuration + health_check_endpoint="https://api.anthropic.com/v1/messages", + health_check_method="POST", + # Credential store mapping + credential_id="anthropic", + credential_key="api_key", ), # Future LLM providers: # "openai": CredentialSpec( diff --git a/tools/src/aden_tools/credentials/search.py b/tools/src/aden_tools/credentials/search.py index 0f180c74..feb65977 100644 --- a/tools/src/aden_tools/credentials/search.py +++ b/tools/src/aden_tools/credentials/search.py @@ -15,6 +15,20 @@ SEARCH_CREDENTIALS = { startup_required=False, help_url="https://brave.com/search/api/", description="API key for Brave Search", + # Auth method support + direct_api_key_supported=True, + api_key_instructions="""To get a Brave Search API key: +1. Go to https://brave.com/search/api/ +2. Create a Brave Search API account (or sign in) +3. Choose a plan (Free tier includes 2,000 queries/month) +4. Navigate to the API Keys section in your dashboard +5. Click "Create API Key" and give it a name +6. Copy the API key and store it securely""", + # Health check configuration + health_check_endpoint="https://api.search.brave.com/res/v1/web/search", + # Credential store mapping + credential_id="brave_search", + credential_key="api_key", ), "google_search": CredentialSpec( env_var="GOOGLE_API_KEY", @@ -22,8 +36,24 @@ SEARCH_CREDENTIALS = { node_types=[], required=True, startup_required=False, - help_url="https://console.cloud.google.com/", + help_url="https://console.cloud.google.com/apis/credentials", description="API key for Google Custom Search", + # Auth method support + direct_api_key_supported=True, + api_key_instructions="""To get a Google Custom Search API key: +1. Go to https://console.cloud.google.com/apis/credentials +2. Create a new project (or select an existing one) +3. Enable the "Custom Search API" from the API Library +4. Go to Credentials > Create Credentials > API Key +5. Copy the generated API key +6. (Recommended) Click "Restrict Key" and limit it to the Custom Search API +7. Store the key securely""", + # Health check configuration + health_check_endpoint="https://www.googleapis.com/customsearch/v1", + # Credential store mapping + credential_id="google_search", + credential_key="api_key", + credential_group="google_custom_search", ), "google_cse": CredentialSpec( env_var="GOOGLE_CSE_ID", @@ -31,7 +61,22 @@ SEARCH_CREDENTIALS = { node_types=[], required=True, startup_required=False, - help_url="https://programmablesearchengine.google.com/", + help_url="https://programmablesearchengine.google.com/controlpanel/all", description="Google Custom Search Engine ID", + # Auth method support + direct_api_key_supported=True, + api_key_instructions="""To get a Google Custom Search Engine (CSE) ID: +1. Go to https://programmablesearchengine.google.com/controlpanel/all +2. Click "Add" to create a new search engine +3. Under "What to search", select "Search the entire web" +4. Give your search engine a name (e.g., "Hive Agent Search") +5. Click "Create" +6. Copy the Search Engine ID (cx value) from the overview page""", + # Health check configuration + health_check_endpoint="https://www.googleapis.com/customsearch/v1", + # Credential store mapping + credential_id="google_cse", + credential_key="api_key", + credential_group="google_custom_search", ), } diff --git a/tools/src/aden_tools/credentials/store_adapter.py b/tools/src/aden_tools/credentials/store_adapter.py index 2fd735e6..b405ed42 100644 --- a/tools/src/aden_tools/credentials/store_adapter.py +++ b/tools/src/aden_tools/credentials/store_adapter.py @@ -348,6 +348,41 @@ class CredentialStoreAdapter: # --- Factory Methods --- + @classmethod + def default( + cls, + specs: dict[str, CredentialSpec] | None = None, + ) -> CredentialStoreAdapter: + """Create adapter with encrypted storage primary and env var fallback.""" + from framework.credentials import CredentialStore + from framework.credentials.storage import ( + CompositeStorage, + EncryptedFileStorage, + EnvVarStorage, + ) + + if specs is None: + from . import CREDENTIAL_SPECS + + specs = CREDENTIAL_SPECS + + env_mapping = {name: spec.env_var for name, spec in specs.items()} + + try: + encrypted = EncryptedFileStorage() + env = EnvVarStorage(env_mapping) + composite = CompositeStorage(primary=encrypted, fallbacks=[env]) + store = CredentialStore(storage=composite) + except Exception as e: + import logging + + logging.getLogger(__name__).warning( + "Encrypted credential storage unavailable, falling back to env vars: %s", e + ) + store = CredentialStore.with_env_storage(env_mapping) + + return cls(store=store, specs=specs) + @classmethod def for_testing( cls, diff --git a/tools/src/aden_tools/tools/__init__.py b/tools/src/aden_tools/tools/__init__.py index c907775c..01faebfc 100644 --- a/tools/src/aden_tools/tools/__init__.py +++ b/tools/src/aden_tools/tools/__init__.py @@ -7,7 +7,7 @@ Usage: from aden_tools.credentials import CredentialStoreAdapter mcp = FastMCP("my-server") - credentials = CredentialStoreAdapter.with_env_storage() + credentials = CredentialStoreAdapter.default() register_all_tools(mcp, credentials=credentials) """ @@ -115,6 +115,9 @@ def register_all_tools( "github_search_code", "github_list_branches", "github_get_branch", + "github_list_stargazers", + "github_get_user_profile", + "github_get_user_emails", "send_email", "send_budget_alert_email", "hubspot_search_contacts", diff --git a/tools/src/aden_tools/tools/email_tool/email_tool.py b/tools/src/aden_tools/tools/email_tool/email_tool.py index 37e97b46..8dd4545c 100644 --- a/tools/src/aden_tools/tools/email_tool/email_tool.py +++ b/tools/src/aden_tools/tools/email_tool/email_tool.py @@ -113,6 +113,16 @@ def register_tools( cc_list = _normalize_recipients(cc) bcc_list = _normalize_recipients(bcc) + # Testing override: redirect all recipients to a single address. + # Set EMAIL_OVERRIDE_TO=you@example.com to intercept all outbound mail. + override_to = os.getenv("EMAIL_OVERRIDE_TO") + if override_to: + original_to = to_list + to_list = [override_to] + cc_list = None + bcc_list = None + subject = f"[TEST -> {', '.join(original_to)}] {subject}" + creds = _get_credentials() resend_available = bool(creds["resend_api_key"]) diff --git a/tools/src/aden_tools/tools/github_tool/github_tool.py b/tools/src/aden_tools/tools/github_tool/github_tool.py index e699ec39..042bedc3 100644 --- a/tools/src/aden_tools/tools/github_tool/github_tool.py +++ b/tools/src/aden_tools/tools/github_tool/github_tool.py @@ -175,6 +175,7 @@ class _GitHubClient: owner: str, repo: str, state: str = "open", + page: int = 1, limit: int = 30, ) -> dict[str, Any]: """List issues for a repository.""" @@ -183,6 +184,7 @@ class _GitHubClient: params = { "state": state, "per_page": min(limit, 100), + "page": max(1, page), } response = httpx.get( @@ -275,6 +277,7 @@ class _GitHubClient: owner: str, repo: str, state: str = "open", + page: int = 1, limit: int = 30, ) -> dict[str, Any]: """List pull requests for a repository.""" @@ -283,6 +286,7 @@ class _GitHubClient: params = { "state": state, "per_page": min(limit, 100), + "page": max(1, page), } response = httpx.get( @@ -400,6 +404,89 @@ class _GitHubClient: ) return self._handle_response(response) + # --- Stargazers --- + + def list_stargazers( + self, + owner: str, + repo: str, + page: int = 1, + limit: int = 30, + ) -> dict[str, Any]: + """List users who starred a repository.""" + owner = _sanitize_path_param(owner, "owner") + repo = _sanitize_path_param(repo, "repo") + params = { + "per_page": min(limit, 100), + "page": max(1, page), + } + + response = httpx.get( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}/stargazers", + headers=self._headers, + params=params, + timeout=30.0, + ) + return self._handle_response(response) + + # --- Users --- + + def get_user_profile( + self, + username: str, + ) -> dict[str, Any]: + """Get a user's public profile.""" + username = _sanitize_path_param(username, "username") + response = httpx.get( + f"{GITHUB_API_BASE}/users/{username}", + headers=self._headers, + timeout=30.0, + ) + return self._handle_response(response) + + def get_user_emails( + self, + username: str, + ) -> dict[str, Any]: + """Find a user's email addresses from their public activity. + + The /users/{username} endpoint only returns the public email + (which most users leave blank). This method also checks the + user's recent public events for commit-author emails. + """ + username = _sanitize_path_param(username, "username") + + emails: dict[str, str] = {} # email -> source + + # 1. Check profile for public email + profile = self.get_user_profile(username) + if isinstance(profile, dict) and "error" not in profile: + if profile.get("email"): + emails[profile["email"]] = "profile" + + # 2. Check recent public events for commit emails + response = httpx.get( + f"{GITHUB_API_BASE}/users/{username}/events/public", + headers=self._headers, + params={"per_page": 30}, + timeout=30.0, + ) + if response.status_code == 200: + for event in response.json(): + if event.get("type") != "PushEvent": + continue + for commit in event.get("payload", {}).get("commits", []): + author = commit.get("author", {}) + email = author.get("email", "") + if email and "@" in email and "noreply" not in email.lower(): + emails[email] = "commit" + + return { + "username": username, + "emails": [{"email": e, "source": s} for e, s in emails.items()], + "total": len(emails), + } + def register_tools( mcp: FastMCP, @@ -522,6 +609,7 @@ def register_tools( owner: str, repo: str, state: str = "open", + page: int = 1, limit: int = 30, ) -> dict: """ @@ -531,7 +619,8 @@ def register_tools( owner: Repository owner repo: Repository name state: Issue state ("open", "closed", "all") - limit: Maximum number of issues to return (1-100, default 30) + page: Page number for pagination (1-based, default 1) + limit: Maximum number of issues per page (1-100, default 30) Returns: Dict with list of issues or error @@ -540,7 +629,7 @@ def register_tools( if isinstance(client, dict): return client try: - return client.list_issues(owner, repo, state, limit) + return client.list_issues(owner, repo, state, page, limit) except httpx.TimeoutException: return {"error": "Request timed out"} except httpx.RequestError as e: @@ -648,6 +737,7 @@ def register_tools( owner: str, repo: str, state: str = "open", + page: int = 1, limit: int = 30, ) -> dict: """ @@ -657,7 +747,8 @@ def register_tools( owner: Repository owner repo: Repository name state: PR state ("open", "closed", "all") - limit: Maximum number of PRs to return (1-100, default 30) + page: Page number for pagination (1-based, default 1) + limit: Maximum number of PRs per page (1-100, default 30) Returns: Dict with list of pull requests or error @@ -666,7 +757,7 @@ def register_tools( if isinstance(client, dict): return client try: - return client.list_pull_requests(owner, repo, state, limit) + return client.list_pull_requests(owner, repo, state, page, limit) except httpx.TimeoutException: return {"error": "Request timed out"} except httpx.RequestError as e: @@ -816,3 +907,85 @@ def register_tools( return {"error": "Request timed out"} except httpx.RequestError as e: return {"error": _sanitize_error_message(e)} + + # --- Stargazers --- + + @mcp.tool() + def github_list_stargazers( + owner: str, + repo: str, + page: int = 1, + limit: int = 30, + ) -> dict: + """ + List users who starred a repository. + + Args: + owner: Repository owner + repo: Repository name + page: Page number for pagination (1-based, default 1) + limit: Maximum number of stargazers per page (1-100, default 30) + + Returns: + Dict with list of stargazers or error + """ + client = _get_client() + if isinstance(client, dict): + return client + try: + return client.list_stargazers(owner, repo, page, limit) + except httpx.TimeoutException: + return {"error": "Request timed out"} + except httpx.RequestError as e: + return {"error": _sanitize_error_message(e)} + + # --- Users --- + + @mcp.tool() + def github_get_user_profile( + username: str, + ) -> dict: + """ + Get a GitHub user's public profile including name, bio, company, location, and email. + + Args: + username: GitHub username + + Returns: + Dict with user profile information or error + """ + client = _get_client() + if isinstance(client, dict): + return client + try: + return client.get_user_profile(username) + except httpx.TimeoutException: + return {"error": "Request timed out"} + except httpx.RequestError as e: + return {"error": _sanitize_error_message(e)} + + @mcp.tool() + def github_get_user_emails( + username: str, + ) -> dict: + """ + Find a GitHub user's email addresses from their public activity. + + Checks both the user's profile (public email) and their recent + push events for commit-author emails. Filters out noreply addresses. + + Args: + username: GitHub username + + Returns: + Dict with emails list (each with email and source), total count + """ + client = _get_client() + if isinstance(client, dict): + return client + try: + return client.get_user_emails(username) + except httpx.TimeoutException: + return {"error": "Request timed out"} + except httpx.RequestError as e: + return {"error": _sanitize_error_message(e)} diff --git a/tools/tests/test_credentials.py b/tools/tests/test_credentials.py index b07c776c..4e8ff575 100644 --- a/tools/tests/test_credentials.py +++ b/tools/tests/test_credentials.py @@ -10,6 +10,17 @@ from aden_tools.credentials import ( ) +@pytest.fixture(autouse=True) +def _no_dotenv(tmp_path, monkeypatch): + """Isolate tests from the project .env file. + + EnvVarStorage falls back to reading Path.cwd()/.env when a key is + missing from os.environ. Changing cwd to a temp dir ensures + monkeypatch.delenv() truly simulates a missing credential. + """ + monkeypatch.chdir(tmp_path) + + class TestCredentialStoreAdapter: """Tests for CredentialStoreAdapter class.""" @@ -438,3 +449,38 @@ class TestStartupValidation: # Should not raise creds.validate_startup() + + +class TestSpecCompleteness: + """Tests that all credential specs have required fields populated.""" + + def test_direct_api_key_specs_have_instructions(self): + """All specs with direct_api_key_supported=True have non-empty api_key_instructions.""" + for name, spec in CREDENTIAL_SPECS.items(): + if spec.direct_api_key_supported: + assert spec.api_key_instructions.strip(), ( + f"Credential '{name}' has direct_api_key_supported=True " + f"but empty api_key_instructions" + ) + + def test_all_specs_have_credential_id(self): + """All credential specs have a non-empty credential_id.""" + for name, spec in CREDENTIAL_SPECS.items(): + assert spec.credential_id, f"Credential '{name}' is missing credential_id" + + def test_google_search_and_cse_share_credential_group(self): + """google_search and google_cse share the same credential_group.""" + google_search = CREDENTIAL_SPECS["google_search"] + google_cse = CREDENTIAL_SPECS["google_cse"] + + assert google_search.credential_group == "google_custom_search" + assert google_cse.credential_group == "google_custom_search" + assert google_search.credential_group == google_cse.credential_group + + def test_credential_group_default_empty(self): + """Specs without a group have empty credential_group.""" + for name, spec in CREDENTIAL_SPECS.items(): + if name not in ("google_search", "google_cse"): + assert spec.credential_group == "", ( + f"Credential '{name}' has unexpected credential_group='{spec.credential_group}'" + ) diff --git a/tools/tests/test_health_checks.py b/tools/tests/test_health_checks.py new file mode 100644 index 00000000..13d238cc --- /dev/null +++ b/tools/tests/test_health_checks.py @@ -0,0 +1,308 @@ +"""Tests for credential health checkers.""" + +from unittest.mock import MagicMock, patch + +import httpx + +from aden_tools.credentials.health_check import ( + HEALTH_CHECKERS, + AnthropicHealthChecker, + GitHubHealthChecker, + GoogleSearchHealthChecker, + ResendHealthChecker, + check_credential_health, +) + + +class TestHealthCheckerRegistry: + """Tests for the HEALTH_CHECKERS registry.""" + + def test_google_search_registered(self): + """GoogleSearchHealthChecker is registered in HEALTH_CHECKERS.""" + assert "google_search" in HEALTH_CHECKERS + assert isinstance(HEALTH_CHECKERS["google_search"], GoogleSearchHealthChecker) + + def test_anthropic_registered(self): + """AnthropicHealthChecker is registered in HEALTH_CHECKERS.""" + assert "anthropic" in HEALTH_CHECKERS + assert isinstance(HEALTH_CHECKERS["anthropic"], AnthropicHealthChecker) + + def test_github_registered(self): + """GitHubHealthChecker is registered in HEALTH_CHECKERS.""" + assert "github" in HEALTH_CHECKERS + assert isinstance(HEALTH_CHECKERS["github"], GitHubHealthChecker) + + def test_resend_registered(self): + """ResendHealthChecker is registered in HEALTH_CHECKERS.""" + assert "resend" in HEALTH_CHECKERS + assert isinstance(HEALTH_CHECKERS["resend"], ResendHealthChecker) + + def test_all_expected_checkers_registered(self): + """All expected health checkers are in the registry.""" + expected = {"hubspot", "brave_search", "google_search", "anthropic", "github", "resend"} + assert set(HEALTH_CHECKERS.keys()) == expected + + +class TestAnthropicHealthChecker: + """Tests for AnthropicHealthChecker.""" + + def _mock_response(self, status_code, json_data=None): + response = MagicMock(spec=httpx.Response) + response.status_code = status_code + if json_data: + response.json.return_value = json_data + return response + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_valid_key_200(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = self._mock_response(200) + + checker = AnthropicHealthChecker() + result = checker.check("sk-ant-test-key") + + assert result.valid is True + assert "valid" in result.message.lower() + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_invalid_key_401(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = self._mock_response(401) + + checker = AnthropicHealthChecker() + result = checker.check("invalid-key") + + assert result.valid is False + assert result.details["status_code"] == 401 + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_rate_limited_429(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = self._mock_response(429) + + checker = AnthropicHealthChecker() + result = checker.check("sk-ant-test-key") + + assert result.valid is True + assert result.details.get("rate_limited") is True + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_bad_request_400_still_valid(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = self._mock_response(400) + + checker = AnthropicHealthChecker() + result = checker.check("sk-ant-test-key") + + assert result.valid is True + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_timeout(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.side_effect = httpx.TimeoutException("timed out") + + checker = AnthropicHealthChecker() + result = checker.check("sk-ant-test-key") + + assert result.valid is False + assert result.details["error"] == "timeout" + + +class TestGitHubHealthChecker: + """Tests for GitHubHealthChecker.""" + + def _mock_response(self, status_code, json_data=None): + response = MagicMock(spec=httpx.Response) + response.status_code = status_code + if json_data: + response.json.return_value = json_data + return response + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_valid_token_200(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = self._mock_response(200, {"login": "testuser"}) + + checker = GitHubHealthChecker() + result = checker.check("ghp_test-token") + + assert result.valid is True + assert "testuser" in result.message + assert result.details["username"] == "testuser" + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_invalid_token_401(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = self._mock_response(401) + + checker = GitHubHealthChecker() + result = checker.check("invalid-token") + + assert result.valid is False + assert result.details["status_code"] == 401 + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_forbidden_403(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = self._mock_response(403) + + checker = GitHubHealthChecker() + result = checker.check("ghp_test-token") + + assert result.valid is False + assert result.details["status_code"] == 403 + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_timeout(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.side_effect = httpx.TimeoutException("timed out") + + checker = GitHubHealthChecker() + result = checker.check("ghp_test-token") + + assert result.valid is False + assert result.details["error"] == "timeout" + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_request_error(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.side_effect = httpx.RequestError("connection failed") + + checker = GitHubHealthChecker() + result = checker.check("ghp_test-token") + + assert result.valid is False + assert "connection failed" in result.details["error"] + + +class TestResendHealthChecker: + """Tests for ResendHealthChecker.""" + + def _mock_response(self, status_code, json_data=None): + response = MagicMock(spec=httpx.Response) + response.status_code = status_code + if json_data: + response.json.return_value = json_data + return response + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_valid_key_200(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = self._mock_response(200) + + checker = ResendHealthChecker() + result = checker.check("re_test-key") + + assert result.valid is True + assert "valid" in result.message.lower() + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_invalid_key_401(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = self._mock_response(401) + + checker = ResendHealthChecker() + result = checker.check("invalid-key") + + assert result.valid is False + assert result.details["status_code"] == 401 + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_forbidden_403(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = self._mock_response(403) + + checker = ResendHealthChecker() + result = checker.check("re_test-key") + + assert result.valid is False + assert result.details["status_code"] == 403 + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_timeout(self, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.side_effect = httpx.TimeoutException("timed out") + + checker = ResendHealthChecker() + result = checker.check("re_test-key") + + assert result.valid is False + assert result.details["error"] == "timeout" + + +class TestCheckCredentialHealthDispatcher: + """Tests for the check_credential_health() top-level dispatcher.""" + + def test_unknown_credential_returns_valid(self): + """Unregistered credential names are assumed valid.""" + result = check_credential_health("nonexistent_service", "some-key") + + assert result.valid is True + assert result.details.get("no_checker") is True + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_dispatches_to_registered_checker(self, mock_client_cls): + """Normal dispatch calls the registered checker.""" + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + response = MagicMock(spec=httpx.Response) + response.status_code = 200 + mock_client.get.return_value = response + + result = check_credential_health("brave_search", "test-key") + + assert result.valid is True + mock_client.get.assert_called_once() + + @patch("aden_tools.credentials.health_check.httpx.Client") + def test_google_search_with_cse_id(self, mock_client_cls): + """google_search special case passes cse_id to checker.""" + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + response = MagicMock(spec=httpx.Response) + response.status_code = 200 + mock_client.get.return_value = response + + result = check_credential_health("google_search", "api-key", cse_id="cse-123") + + assert result.valid is True + # Verify the request included the cse_id as the cx param + call_kwargs = mock_client.get.call_args + assert call_kwargs[1]["params"]["cx"] == "cse-123" + + def test_google_search_without_cse_id(self): + """google_search without cse_id does partial check (no HTTP call).""" + result = check_credential_health("google_search", "api-key") + + assert result.valid is True + assert result.details.get("partial_check") is True