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)[^>]*>.*?\1>",
- "",
- 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