Merge branch 'main' into fix/retired-haiku-3.5-model
This commit is contained in:
@@ -126,6 +126,8 @@ feat(component): add new feature description
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions focused and small
|
||||
|
||||
For linting and formatting (Ruff, pre-commit hooks), see [Linting & Formatting Setup](docs/contributing-lint-setup.md).
|
||||
|
||||
## Testing
|
||||
|
||||
> **Note:** When testing agents in `exports/`, always set PYTHONPATH:
|
||||
|
||||
@@ -87,7 +87,7 @@ Use Hive when you need:
|
||||
|
||||
### Installation
|
||||
|
||||
>**Note**
|
||||
> **Note**
|
||||
> Hive uses a `uv` workspace layout and is not installed with `pip install`.
|
||||
> Running `pip install -e .` from the repository root will create a placeholder package and Hive will not function correctly.
|
||||
> Please use the quickstart script below to set up the environment.
|
||||
@@ -124,8 +124,11 @@ hive tui
|
||||
# Or run directly
|
||||
hive run exports/your_agent_name --input '{"key": "value"}'
|
||||
```
|
||||
## Coding Agent Support
|
||||
|
||||
## Coding Agent Support
|
||||
|
||||
### Codex CLI
|
||||
|
||||
Hive includes native support for [OpenAI Codex CLI](https://github.com/openai/codex) (v0.101.0+).
|
||||
|
||||
1. **Config:** `.codex/config.toml` with `agent-builder` MCP server (tracked in git)
|
||||
@@ -133,17 +136,19 @@ Hive includes native support for [OpenAI Codex CLI](https://github.com/openai/co
|
||||
3. **Launch:** Run `codex` in the repo root, then type `use hive`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
codex> use hive
|
||||
```
|
||||
|
||||
### Opencode
|
||||
### Opencode
|
||||
|
||||
Hive includes native support for [Opencode](https://github.com/opencode-ai/opencode).
|
||||
|
||||
1. **Setup:** Run the quickstart script
|
||||
1. **Setup:** Run the quickstart script
|
||||
2. **Launch:** Open Opencode in the project root.
|
||||
3. **Activate:** Type `/hive` in the chat to switch to the Hive Agent.
|
||||
4. **Verify:** Ask the agent *"List your tools"* to confirm the connection.
|
||||
4. **Verify:** Ask the agent _"List your tools"_ to confirm the connection.
|
||||
|
||||
The agent has access to all Hive skills and can scaffold agents, add tools, and debug workflows directly from the chat.
|
||||
|
||||
@@ -180,7 +185,6 @@ Hive is built to be model-agnostic and system-agnostic.
|
||||
- **LLM flexibility** - Hive Framework is designed to support various types of LLMs, including hosted and local models through LiteLLM-compatible providers.
|
||||
- **Business system connectivity** - Hive Framework is designed to connect to all kinds of business systems as tools, such as CRM, support, messaging, data, file, and internal APIs via MCP.
|
||||
|
||||
|
||||
## Why Aden
|
||||
|
||||
Hive focuses on generating agents that run real business processes rather than generic agents. Instead of requiring you to manually design workflows, define agent interactions, and handle failures reactively, Hive flips the paradigm: **you describe outcomes, and the system builds itself**—delivering an outcome-driven, adaptive experience with an easy-to-use set of tools and integrations.
|
||||
@@ -273,93 +277,124 @@ See [environment-setup.md](docs/environment-setup.md) for complete setup instruc
|
||||
Aden Hive Agent Framework aims to help developers build outcome-oriented, self-adaptive agents. See [roadmap.md](docs/roadmap.md) for details.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Foundation
|
||||
direction LR
|
||||
subgraph arch["Architecture"]
|
||||
a1["Node-Based Architecture"]:::done
|
||||
a2["Python SDK"]:::done
|
||||
a3["LLM Integration"]:::done
|
||||
a4["Communication Protocol"]:::done
|
||||
end
|
||||
subgraph ca["Coding Agent"]
|
||||
b1["Goal Creation Session"]:::done
|
||||
b2["Worker Agent Creation"]
|
||||
b3["MCP Tools"]:::done
|
||||
end
|
||||
subgraph wa["Worker Agent"]
|
||||
c1["Human-in-the-Loop"]:::done
|
||||
c2["Callback Handlers"]:::done
|
||||
c3["Intervention Points"]:::done
|
||||
c4["Streaming Interface"]
|
||||
end
|
||||
subgraph cred["Credentials"]
|
||||
d1["Setup Process"]:::done
|
||||
d2["Pluggable Sources"]:::done
|
||||
d3["Enterprise Secrets"]
|
||||
d4["Integration Tools"]:::done
|
||||
end
|
||||
subgraph tools["Tools"]
|
||||
e1["File Use"]:::done
|
||||
e2["Memory STM/LTM"]:::done
|
||||
e3["Web Search/Scraper"]:::done
|
||||
e4["CSV/PDF"]:::done
|
||||
e5["Excel/Email"]
|
||||
end
|
||||
subgraph core["Core"]
|
||||
f1["Eval System"]
|
||||
f2["Pydantic Validation"]:::done
|
||||
f3["Documentation"]:::done
|
||||
f4["Adaptiveness"]
|
||||
f5["Sample Agents"]
|
||||
end
|
||||
end
|
||||
flowchart TB
|
||||
%% Main Entity
|
||||
User([User])
|
||||
|
||||
subgraph Expansion
|
||||
direction LR
|
||||
subgraph intel["Intelligence"]
|
||||
g1["Guardrails"]
|
||||
g2["Streaming Mode"]
|
||||
g3["Image Generation"]
|
||||
g4["Semantic Search"]
|
||||
%% =========================================
|
||||
%% EXTERNAL EVENT SOURCES
|
||||
%% =========================================
|
||||
subgraph ExtEventSource [External Event Source]
|
||||
E_Sch["Schedulers"]
|
||||
E_WH["Webhook"]
|
||||
E_SSE["SSE"]
|
||||
end
|
||||
subgraph mem["Memory Iteration"]
|
||||
h1["Message Model & Sessions"]
|
||||
h2["Storage Migration"]
|
||||
h3["Context Building"]
|
||||
h4["Proactive Compaction"]
|
||||
h5["Token Tracking"]
|
||||
end
|
||||
subgraph evt["Event System"]
|
||||
i1["Event Bus for Nodes"]
|
||||
end
|
||||
subgraph cas["Coding Agent Support"]
|
||||
j1["Claude Code"]
|
||||
j2["Cursor"]
|
||||
j3["Opencode"]
|
||||
j4["Antigravity"]
|
||||
j5["Codex CLI"]
|
||||
end
|
||||
subgraph plat["Platform"]
|
||||
k1["JavaScript/TypeScript SDK"]
|
||||
k2["Custom Tool Integrator"]
|
||||
k3["Windows Support"]
|
||||
end
|
||||
subgraph dep["Deployment"]
|
||||
l1["Self-Hosted"]
|
||||
l2["Cloud Services"]
|
||||
l3["CI/CD Pipeline"]
|
||||
end
|
||||
subgraph tmpl["Templates"]
|
||||
m1["Sales Agent"]
|
||||
m2["Marketing Agent"]
|
||||
m3["Analytics Agent"]
|
||||
m4["Training Agent"]
|
||||
m5["Smart Form Agent"]
|
||||
end
|
||||
end
|
||||
|
||||
classDef done fill:#9e9e9e,color:#fff,stroke:#757575
|
||||
%% =========================================
|
||||
%% SYSTEM NODES
|
||||
%% =========================================
|
||||
subgraph WorkerBees [Worker Bees]
|
||||
WB_C["Conversation"]
|
||||
WB_SP["System prompt"]
|
||||
|
||||
subgraph Graph [Graph]
|
||||
direction TB
|
||||
N1["Node"] --> N2["Node"] --> N3["Node"]
|
||||
N1 -.-> AN["Active Node"]
|
||||
N2 -.-> AN
|
||||
N3 -.-> AN
|
||||
|
||||
%% Nested Event Loop Node
|
||||
subgraph EventLoopNode [Event Loop Node]
|
||||
ELN_L["listener"]
|
||||
ELN_SP["System Prompt<br/>(Task)"]
|
||||
ELN_EL["Event loop"]
|
||||
ELN_C["Conversation"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
subgraph JudgeNode [Judge]
|
||||
J_C["Criteria"]
|
||||
J_P["Principles"]
|
||||
J_EL["Event loop"] <--> J_S["Scheduler"]
|
||||
end
|
||||
|
||||
subgraph QueenBee [Queen Bee]
|
||||
QB_SP["System prompt"]
|
||||
QB_EL["Event loop"]
|
||||
QB_C["Conversation"]
|
||||
end
|
||||
|
||||
subgraph Infra [Infra]
|
||||
SA["Sub Agent"]
|
||||
TR["Tool Registry"]
|
||||
WTM["Write through Conversation Memory<br/>(Logs/RAM/Harddrive)"]
|
||||
SM["Shared Memory<br/>(State/Harddrive)"]
|
||||
EB["Event Bus<br/>(RAM)"]
|
||||
CS["Credential Store<br/>(Harddrive/Cloud)"]
|
||||
end
|
||||
|
||||
subgraph PC [PC]
|
||||
B["Browser"]
|
||||
CB["Codebase<br/>v 0.0.x ... v n.n.n"]
|
||||
end
|
||||
|
||||
%% =========================================
|
||||
%% CONNECTIONS & DATA FLOW
|
||||
%% =========================================
|
||||
|
||||
%% External Event Routing
|
||||
E_Sch --> ELN_L
|
||||
E_WH --> ELN_L
|
||||
E_SSE --> ELN_L
|
||||
ELN_L -->|"triggers"| ELN_EL
|
||||
|
||||
%% User Interactions
|
||||
User -->|"Talk"| WB_C
|
||||
User -->|"Talk"| QB_C
|
||||
User -->|"Read/Write Access"| CS
|
||||
|
||||
%% Inter-System Logic
|
||||
ELN_C <-->|"Mirror"| WB_C
|
||||
WB_C -->|"Focus"| AN
|
||||
|
||||
WorkerBees -->|"Inquire"| JudgeNode
|
||||
JudgeNode -->|"Approve"| WorkerBees
|
||||
|
||||
%% Judge Alignments
|
||||
J_C <-.->|"aligns"| WB_SP
|
||||
J_P <-.->|"aligns"| QB_SP
|
||||
|
||||
%% Escalate path
|
||||
J_EL -->|"Report (Escalate)"| QB_EL
|
||||
|
||||
%% Pub/Sub Logic
|
||||
AN -->|"publish"| EB
|
||||
EB -->|"subscribe"| QB_C
|
||||
|
||||
%% Infra and Process Spawning
|
||||
ELN_EL -->|"Spawn"| SA
|
||||
SA -->|"Inform"| ELN_EL
|
||||
SA -->|"Starts"| B
|
||||
B -->|"Report"| ELN_EL
|
||||
TR -->|"Assigned"| EventLoopNode
|
||||
CB -->|"Modify Worker Bee"| WorkerBees
|
||||
|
||||
%% =========================================
|
||||
%% SHARED MEMORY & LOGS ACCESS
|
||||
%% =========================================
|
||||
|
||||
%% Worker Bees Access
|
||||
Graph <-->|"Read/Write"| WTM
|
||||
Graph <-->|"Read/Write"| SM
|
||||
|
||||
%% Queen Bee Access
|
||||
QB_C <-->|"Read/Write"| WTM
|
||||
QB_EL <-->|"Read/Write"| SM
|
||||
|
||||
%% Credentials Access
|
||||
CS -->|"Read Access"| QB_C
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"""
|
||||
Credential Tester — verify synced credentials via live API calls.
|
||||
Credential Tester — verify credentials (Aden OAuth + local API keys) via live API calls.
|
||||
|
||||
Interactive agent that lists connected accounts, lets the user pick one,
|
||||
Interactive agent that lists all testable accounts, lets the user pick one,
|
||||
loads the provider's tools, and runs a chat session to test the credential.
|
||||
"""
|
||||
|
||||
from .agent import (
|
||||
CredentialTesterAgent,
|
||||
_list_aden_accounts,
|
||||
_list_env_fallback_accounts,
|
||||
_list_local_accounts,
|
||||
configure_for_account,
|
||||
conversation_mode,
|
||||
edges,
|
||||
entry_node,
|
||||
entry_points,
|
||||
get_tools_for_provider,
|
||||
goal,
|
||||
identity_prompt,
|
||||
list_connected_accounts,
|
||||
@@ -35,6 +39,7 @@ __all__ = [
|
||||
"edges",
|
||||
"entry_node",
|
||||
"entry_points",
|
||||
"get_tools_for_provider",
|
||||
"goal",
|
||||
"identity_prompt",
|
||||
"list_connected_accounts",
|
||||
@@ -45,4 +50,8 @@ __all__ = [
|
||||
"skip_credential_validation",
|
||||
"skip_guardian",
|
||||
"terminal_nodes",
|
||||
# Internal list helpers (exposed for testing)
|
||||
"_list_aden_accounts",
|
||||
"_list_local_accounts",
|
||||
"_list_env_fallback_accounts",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Credential Tester agent — verify synced credentials via live API calls.
|
||||
"""Credential Tester agent — verify credentials via live API calls.
|
||||
|
||||
A framework agent that lets the user pick a connected account and test it
|
||||
by making real API calls via the provider's tools.
|
||||
Supports both Aden OAuth2-synced accounts AND locally-stored API key accounts.
|
||||
Aden accounts use account="alias" routing; local accounts inject the key into
|
||||
the session environment so tools read it without an account= parameter.
|
||||
|
||||
When loaded via AgentRunner.load() (TUI picker, ``hive run``), the module-level
|
||||
``nodes`` / ``edges`` variables provide a static graph. The TUI detects
|
||||
@@ -40,7 +41,7 @@ if TYPE_CHECKING:
|
||||
goal = Goal(
|
||||
id="credential-tester",
|
||||
name="Credential Tester",
|
||||
description="Verify that a synced credential can make real API calls.",
|
||||
description="Verify that a credential can make real API calls.",
|
||||
success_criteria=[
|
||||
SuccessCriterion(
|
||||
id="api-call-success",
|
||||
@@ -59,52 +60,148 @@ goal = Goal(
|
||||
|
||||
|
||||
def get_tools_for_provider(provider_name: str) -> list[str]:
|
||||
"""Collect tool names for a specific Aden credential by credential_id.
|
||||
"""Collect tool names for a credential by credential_id OR credential_group.
|
||||
|
||||
Matches on ``credential_id`` (e.g. "google" → Gmail tools only),
|
||||
NOT ``aden_provider_name`` which can be shared across products
|
||||
(e.g. both google and google_docs have aden_provider_name="google").
|
||||
Matches on both ``credential_id`` (e.g. "google" → Gmail tools) and
|
||||
``credential_group`` (e.g. "google_custom_search" → all google search tools).
|
||||
"""
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
tools: list[str] = []
|
||||
for spec in CREDENTIAL_SPECS.values():
|
||||
if spec.credential_id == provider_name:
|
||||
if spec.credential_id == provider_name or spec.credential_group == provider_name:
|
||||
tools.extend(spec.tools)
|
||||
return sorted(set(tools))
|
||||
|
||||
|
||||
def list_connected_accounts() -> list[dict]:
|
||||
"""List connected accounts from GET /v1/credentials."""
|
||||
def _list_aden_accounts() -> list[dict]:
|
||||
"""List active accounts from the Aden platform (requires ADEN_API_KEY)."""
|
||||
import os
|
||||
|
||||
from framework.credentials.aden.client import AdenClientConfig, AdenCredentialClient
|
||||
|
||||
api_key = os.environ.get("ADEN_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
client = AdenCredentialClient(
|
||||
AdenClientConfig(
|
||||
base_url=os.environ.get("ADEN_API_URL", "https://api.adenhq.com"),
|
||||
)
|
||||
)
|
||||
try:
|
||||
integrations = client.list_integrations()
|
||||
finally:
|
||||
client.close()
|
||||
from framework.credentials.aden.client import AdenClientConfig, AdenCredentialClient
|
||||
|
||||
return [
|
||||
{
|
||||
"provider": c.provider,
|
||||
"alias": c.alias,
|
||||
"identity": {"email": c.email} if c.email else {},
|
||||
"integration_id": c.integration_id,
|
||||
}
|
||||
for c in integrations
|
||||
if c.status == "active"
|
||||
client = AdenCredentialClient(
|
||||
AdenClientConfig(
|
||||
base_url=os.environ.get("ADEN_API_URL", "https://api.adenhq.com"),
|
||||
)
|
||||
)
|
||||
try:
|
||||
integrations = client.list_integrations()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
return [
|
||||
{
|
||||
"provider": c.provider,
|
||||
"alias": c.alias,
|
||||
"identity": {"email": c.email} if c.email else {},
|
||||
"integration_id": c.integration_id,
|
||||
"source": "aden",
|
||||
}
|
||||
for c in integrations
|
||||
if c.status == "active"
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _list_local_accounts() -> list[dict]:
|
||||
"""List named local API key accounts from LocalCredentialRegistry."""
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
return [
|
||||
info.to_account_dict() for info in LocalCredentialRegistry.default().list_accounts()
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _list_env_fallback_accounts() -> list[dict]:
|
||||
"""Surface configured-but-unregistered credentials as testable entries.
|
||||
|
||||
Detects credentials available via env vars OR stored in the encrypted
|
||||
store in the old flat format (e.g. ``brave_search`` with no alias).
|
||||
These are users who haven't yet run ``save_account()`` but have a working key.
|
||||
Shows with alias="default" and status="unknown".
|
||||
"""
|
||||
import os
|
||||
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
# Collect IDs in encrypted store (includes old flat entries like "brave_search")
|
||||
try:
|
||||
from framework.credentials.storage import EncryptedFileStorage
|
||||
|
||||
encrypted_ids: set[str] = set(EncryptedFileStorage().list_all())
|
||||
except Exception:
|
||||
encrypted_ids = set()
|
||||
|
||||
def _is_configured(cred_name: str, spec) -> bool:
|
||||
# 1. Env var present
|
||||
if os.environ.get(spec.env_var):
|
||||
return True
|
||||
# 2. Old flat encrypted entry (no slash — new entries have {x}/{y})
|
||||
if cred_name in encrypted_ids:
|
||||
return True
|
||||
return False
|
||||
|
||||
seen_groups: set[str] = set()
|
||||
accounts: list[dict] = []
|
||||
|
||||
for cred_name, spec in CREDENTIAL_SPECS.items():
|
||||
if not spec.direct_api_key_supported or not spec.tools:
|
||||
continue
|
||||
|
||||
if spec.credential_group:
|
||||
if spec.credential_group in seen_groups:
|
||||
continue
|
||||
group_available = all(
|
||||
_is_configured(n, s)
|
||||
for n, s in CREDENTIAL_SPECS.items()
|
||||
if s.credential_group == spec.credential_group
|
||||
)
|
||||
if not group_available:
|
||||
continue
|
||||
seen_groups.add(spec.credential_group)
|
||||
provider = spec.credential_group
|
||||
else:
|
||||
if not _is_configured(cred_name, spec):
|
||||
continue
|
||||
provider = cred_name
|
||||
|
||||
accounts.append(
|
||||
{
|
||||
"provider": provider,
|
||||
"alias": "default",
|
||||
"identity": {},
|
||||
"integration_id": None,
|
||||
"source": "local",
|
||||
"status": "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
def list_connected_accounts() -> list[dict]:
|
||||
"""List all testable accounts: Aden-synced + named local + env-var fallbacks."""
|
||||
aden = _list_aden_accounts()
|
||||
local = _list_local_accounts()
|
||||
|
||||
# Show env-var fallbacks only for credentials not already in the named registry
|
||||
local_providers = {a["provider"] for a in local}
|
||||
env_fallbacks = [
|
||||
a for a in _list_env_fallback_accounts() if a["provider"] not in local_providers
|
||||
]
|
||||
|
||||
return aden + local + env_fallbacks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level hooks (read by AgentRunner.load / TUI)
|
||||
@@ -123,22 +220,102 @@ requires_account_selection = True
|
||||
def configure_for_account(runner: AgentRunner, account: dict) -> None:
|
||||
"""Scope the tester node's tools to the selected provider.
|
||||
|
||||
Called by the TUI after the user picks an account from the picker.
|
||||
After scoping, re-enables credential validation so the selected
|
||||
provider's credentials are checked before the agent starts.
|
||||
Handles both Aden accounts (account= routing) and local accounts
|
||||
(session-level env var injection, no account= parameter in prompt).
|
||||
"""
|
||||
provider = account["provider"]
|
||||
tools = get_tools_for_provider(provider)
|
||||
tools.append("get_account_info")
|
||||
|
||||
source = account.get("source", "aden")
|
||||
alias = account.get("alias", "unknown")
|
||||
email = account.get("identity", {}).get("email", "")
|
||||
detail = f" (email: {email})" if email else ""
|
||||
identity = account.get("identity", {})
|
||||
tools = get_tools_for_provider(provider)
|
||||
|
||||
if source == "aden":
|
||||
tools.append("get_account_info")
|
||||
email = identity.get("email", "")
|
||||
detail = f" (email: {email})" if email else ""
|
||||
_configure_aden_node(runner, provider, alias, detail, tools)
|
||||
else:
|
||||
status = account.get("status", "unknown")
|
||||
_activate_local_account(provider, alias)
|
||||
_configure_local_node(runner, provider, alias, identity, tools, status)
|
||||
|
||||
|
||||
def _activate_local_account(credential_id: str, alias: str) -> None:
|
||||
"""Inject a named local account's key into the session environment.
|
||||
|
||||
Handles three cases:
|
||||
1. Named account in LocalCredentialRegistry (new format: {credential_id}/{alias})
|
||||
2. Old flat credential in EncryptedFileStorage (id == credential_id, no alias)
|
||||
3. Env var already set — skip injection (nothing to do)
|
||||
"""
|
||||
import os
|
||||
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
# Collect specs for this credential (handles grouped credentials too)
|
||||
group_specs = [
|
||||
(cred_name, spec)
|
||||
for cred_name, spec in CREDENTIAL_SPECS.items()
|
||||
if spec.credential_group == credential_id
|
||||
or spec.credential_id == credential_id
|
||||
or cred_name == credential_id
|
||||
]
|
||||
# Deduplicate — credential_id and credential_group may both match the same spec
|
||||
seen_env_vars: set[str] = set()
|
||||
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
from framework.credentials.storage import EncryptedFileStorage
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
flat_storage = EncryptedFileStorage()
|
||||
|
||||
for _cred_name, spec in group_specs:
|
||||
if spec.env_var in seen_env_vars:
|
||||
continue
|
||||
# If env var is already set, nothing to do for this one
|
||||
if os.environ.get(spec.env_var):
|
||||
seen_env_vars.add(spec.env_var)
|
||||
continue
|
||||
|
||||
seen_env_vars.add(spec.env_var)
|
||||
|
||||
# Determine key name based on spec
|
||||
key_name = "api_key"
|
||||
if spec.credential_group and "cse" in spec.env_var.lower():
|
||||
key_name = "cse_id"
|
||||
|
||||
key: str | None = None
|
||||
|
||||
# 1. Try named account in registry (new format)
|
||||
if alias != "default":
|
||||
key = registry.get_key(credential_id, alias, key_name)
|
||||
else:
|
||||
# For "default" alias, check registry first, then fall back to flat store
|
||||
key = registry.get_key(credential_id, "default", key_name)
|
||||
|
||||
# 2. Fall back to old flat encrypted entry (id == credential_id, no alias)
|
||||
if key is None:
|
||||
flat_cred = flat_storage.load(credential_id)
|
||||
if flat_cred is not None:
|
||||
key = flat_cred.get_key(key_name) or flat_cred.get_default_key()
|
||||
|
||||
if key:
|
||||
os.environ[spec.env_var] = key
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _configure_aden_node(
|
||||
runner: AgentRunner,
|
||||
provider: str,
|
||||
alias: str,
|
||||
detail: str,
|
||||
tools: list[str],
|
||||
) -> None:
|
||||
for node in runner.graph.nodes:
|
||||
if node.id == "tester":
|
||||
node.tools = sorted(set(tools))
|
||||
# Update system prompt to be provider-specific
|
||||
node.system_prompt = f"""\
|
||||
You are a credential tester for the account: {provider}/{alias}{detail}
|
||||
|
||||
@@ -165,19 +342,60 @@ or any other identifier — always use the alias exactly as shown.
|
||||
"""
|
||||
break
|
||||
|
||||
# Set intro message for TUI display
|
||||
runner.intro_message = (
|
||||
f"Testing {provider}/{alias}{detail} — "
|
||||
f"{len(tools)} tools loaded. "
|
||||
f"I'll suggest a read-only API call to verify the credential works."
|
||||
"I'll suggest a read-only API call to verify the credential works."
|
||||
)
|
||||
|
||||
|
||||
def _configure_local_node(
|
||||
runner: AgentRunner,
|
||||
provider: str,
|
||||
alias: str,
|
||||
identity: dict,
|
||||
tools: list[str],
|
||||
status: str,
|
||||
) -> None:
|
||||
identity_parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
detail = f" ({', '.join(identity_parts)})" if identity_parts else ""
|
||||
status_note = " [key not yet validated]" if status == "unknown" else ""
|
||||
|
||||
for node in runner.graph.nodes:
|
||||
if node.id == "tester":
|
||||
node.tools = sorted(set(tools))
|
||||
node.system_prompt = f"""\
|
||||
You are a credential tester for the local API key: {provider}/{alias}{detail}{status_note}
|
||||
|
||||
# Instructions
|
||||
|
||||
1. Suggest a simple test call to verify the credential works \
|
||||
(e.g. search for "test", list items, get profile info).
|
||||
2. Execute the call when the user agrees.
|
||||
3. Report the result: success (with sample data) or failure (with error).
|
||||
4. Let the user request additional API calls to further test the credential.
|
||||
|
||||
# Rules
|
||||
|
||||
- Do NOT pass an `account` parameter — this credential is injected \
|
||||
directly into the session environment and tools read it automatically.
|
||||
- Start with read-only operations before write operations.
|
||||
- Always confirm with the user before performing write operations.
|
||||
- If a call fails, report the exact error — this helps diagnose credential issues.
|
||||
- Be concise. No emojis.
|
||||
"""
|
||||
break
|
||||
|
||||
runner.intro_message = (
|
||||
f"Testing {provider}/{alias}{detail} — "
|
||||
f"{len(tools)} tools loaded. "
|
||||
"I'll suggest a test API call to verify the credential works."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level graph variables (read by AgentRunner.load)
|
||||
# ---------------------------------------------------------------------------
|
||||
# The static node starts with minimal tools. configure_for_account() scopes
|
||||
# it to the selected provider's tools before execution.
|
||||
|
||||
nodes = [
|
||||
NodeSpec(
|
||||
@@ -195,7 +413,7 @@ nodes = [
|
||||
tools=["get_account_info"],
|
||||
system_prompt="""\
|
||||
You are a credential tester. Your job is to help the user verify that their \
|
||||
connected accounts can make real API calls.
|
||||
connected accounts and API keys can make real API calls.
|
||||
|
||||
# Startup
|
||||
|
||||
@@ -208,12 +426,11 @@ connected accounts can make real API calls.
|
||||
6. Report the result: success (with sample data) or failure (with error).
|
||||
7. Let the user request additional API calls to further test the credential.
|
||||
|
||||
# Account routing
|
||||
# Account routing (Aden accounts only)
|
||||
|
||||
IMPORTANT: Always pass the account's **alias** as the ``account`` parameter \
|
||||
when calling any tool. The alias is the routing key — never use the email or \
|
||||
any other identifier. For example, if the alias is "Timothy", call \
|
||||
``gmail_list_messages(account="Timothy", ...)``.
|
||||
IMPORTANT: For Aden-synced accounts, always pass the account's **alias** as the \
|
||||
``account`` parameter when calling any tool. For local API key accounts, do NOT \
|
||||
pass an account parameter — they are pre-injected into the session.
|
||||
|
||||
# Rules
|
||||
|
||||
@@ -234,7 +451,8 @@ terminal_nodes = [] # Forever-alive: loops until user exits
|
||||
|
||||
conversation_mode = "continuous"
|
||||
identity_prompt = (
|
||||
"You are a credential tester that verifies connected accounts can make real API calls."
|
||||
"You are a credential tester that verifies connected accounts and API keys "
|
||||
"can make real API calls."
|
||||
)
|
||||
loop_config = {
|
||||
"max_iterations": 50,
|
||||
@@ -255,7 +473,6 @@ class CredentialTesterAgent:
|
||||
accounts = agent.list_accounts()
|
||||
agent.select_account(accounts[0])
|
||||
await agent.start()
|
||||
# ... user chats via TUI or CLI ...
|
||||
await agent.stop()
|
||||
"""
|
||||
|
||||
@@ -267,7 +484,7 @@ class CredentialTesterAgent:
|
||||
self._storage_path: Path | None = None
|
||||
|
||||
def list_accounts(self) -> list[dict]:
|
||||
"""List connected accounts from the Aden credential store."""
|
||||
"""List all testable accounts (Aden + local named + env-var fallbacks)."""
|
||||
return list_connected_accounts()
|
||||
|
||||
def select_account(self, account: dict) -> None:
|
||||
@@ -275,7 +492,7 @@ class CredentialTesterAgent:
|
||||
|
||||
Args:
|
||||
account: Account dict from list_accounts() with
|
||||
provider, alias, identity keys.
|
||||
provider, alias, identity, source keys.
|
||||
"""
|
||||
self._selected_account = account
|
||||
|
||||
@@ -294,14 +511,21 @@ class CredentialTesterAgent:
|
||||
def _build_graph(self) -> GraphSpec:
|
||||
provider = self.selected_provider
|
||||
alias = self.selected_alias
|
||||
source = self._selected_account.get("source", "aden")
|
||||
identity = self._selected_account.get("identity", {})
|
||||
tools = get_tools_for_provider(provider)
|
||||
|
||||
if source == "local":
|
||||
_activate_local_account(provider, alias)
|
||||
elif source == "aden":
|
||||
tools.append("get_account_info")
|
||||
|
||||
tester_node = build_tester_node(
|
||||
provider=provider,
|
||||
alias=alias,
|
||||
tools=tools,
|
||||
identity=identity,
|
||||
source=source,
|
||||
)
|
||||
|
||||
return GraphSpec(
|
||||
|
||||
@@ -8,18 +8,38 @@ def build_tester_node(
|
||||
alias: str,
|
||||
tools: list[str],
|
||||
identity: dict[str, str],
|
||||
source: str = "aden",
|
||||
) -> NodeSpec:
|
||||
"""Build the tester node dynamically for the selected account.
|
||||
|
||||
Args:
|
||||
provider: Aden provider name (e.g. "google", "slack").
|
||||
alias: User-set alias (e.g. "Timothy").
|
||||
provider: Provider / credential name (e.g. "google", "brave_search").
|
||||
alias: User-set alias (e.g. "Timothy", "work").
|
||||
tools: Tool names available for this provider.
|
||||
identity: Identity dict (email, workspace, etc.) for context.
|
||||
source: "aden" or "local" — controls routing instructions in the prompt.
|
||||
"""
|
||||
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
|
||||
if source == "aden":
|
||||
routing_section = f"""\
|
||||
# Account routing
|
||||
|
||||
IMPORTANT: Always pass `account="{alias}"` when calling any tool. \
|
||||
This routes the API call to the correct credential. Never use the email \
|
||||
or any other identifier — always use the alias exactly as shown.
|
||||
"""
|
||||
else:
|
||||
routing_section = """\
|
||||
# Credential routing
|
||||
|
||||
This is a local API key credential — do NOT pass an `account` parameter. \
|
||||
The key is pre-injected into the session environment and tools read it automatically.
|
||||
"""
|
||||
|
||||
account_label = "account" if source == "aden" else "local API key"
|
||||
|
||||
return NodeSpec(
|
||||
id="tester",
|
||||
name="Credential Tester",
|
||||
@@ -34,22 +54,17 @@ def build_tester_node(
|
||||
output_keys=[],
|
||||
tools=tools,
|
||||
system_prompt=f"""\
|
||||
You are a credential tester for the account: {provider}/{alias}{detail}
|
||||
You are a credential tester for the {account_label}: {provider}/{alias}{detail}
|
||||
|
||||
Your job is to help the user verify that this credential works by making \
|
||||
real API calls using the available tools.
|
||||
|
||||
# Account routing
|
||||
|
||||
IMPORTANT: Always pass `account="{alias}"` when calling any tool. \
|
||||
This routes the API call to the correct credential. Never use the email \
|
||||
or any other identifier — always use the alias exactly as shown.
|
||||
|
||||
{routing_section}
|
||||
# Instructions
|
||||
|
||||
1. Start by greeting the user and confirming which account you're testing.
|
||||
2. Suggest a simple, safe, read-only API call to verify the credential works \
|
||||
(e.g. list messages, list channels, list contacts).
|
||||
(e.g. list messages, list channels, list contacts, search for "test").
|
||||
3. Execute the call when the user agrees.
|
||||
4. Report the result clearly: success (with sample data) or failure (with error).
|
||||
5. Let the user request additional API calls to further test the credential.
|
||||
|
||||
@@ -92,6 +92,14 @@ try:
|
||||
except ImportError:
|
||||
_ADEN_AVAILABLE = False
|
||||
|
||||
# Local credential registry (named API key accounts with identity metadata)
|
||||
try:
|
||||
from .local import LocalAccountInfo, LocalCredentialRegistry
|
||||
|
||||
_LOCAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
_LOCAL_AVAILABLE = False
|
||||
|
||||
__all__ = [
|
||||
# Main store
|
||||
"CredentialStore",
|
||||
@@ -133,7 +141,11 @@ __all__ = [
|
||||
"AdenCredentialClient",
|
||||
"AdenClientConfig",
|
||||
"AdenCachedStorage",
|
||||
# Local credential registry (optional - requires cryptography)
|
||||
"LocalCredentialRegistry",
|
||||
"LocalAccountInfo",
|
||||
]
|
||||
|
||||
# Track Aden availability for runtime checks
|
||||
ADEN_AVAILABLE = _ADEN_AVAILABLE
|
||||
LOCAL_AVAILABLE = _LOCAL_AVAILABLE
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Local credential registry — named API key accounts with identity metadata.
|
||||
|
||||
Provides feature parity with Aden OAuth credentials for locally-stored API keys:
|
||||
aliases, identity metadata, status tracking, CRUD, and health validation.
|
||||
|
||||
Usage:
|
||||
from framework.credentials.local import LocalCredentialRegistry, LocalAccountInfo
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
|
||||
# Add a named account
|
||||
info, health = registry.save_account("brave_search", "work", "BSA-xxx")
|
||||
|
||||
# List all stored local accounts
|
||||
for account in registry.list_accounts():
|
||||
print(f"{account.credential_id}/{account.alias}: {account.status}")
|
||||
if account.identity.is_known:
|
||||
print(f" Identity: {account.identity.label}")
|
||||
|
||||
# Re-validate a stored account
|
||||
result = registry.validate_account("github", "personal")
|
||||
"""
|
||||
|
||||
from .models import LocalAccountInfo
|
||||
from .registry import LocalCredentialRegistry
|
||||
|
||||
__all__ = [
|
||||
"LocalAccountInfo",
|
||||
"LocalCredentialRegistry",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Data models for the local credential registry.
|
||||
|
||||
LocalAccountInfo mirrors AdenIntegrationInfo, giving local API key credentials
|
||||
the same identity/status metadata as Aden OAuth credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from framework.credentials.models import CredentialIdentity
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalAccountInfo:
|
||||
"""
|
||||
A locally-stored named credential account.
|
||||
|
||||
Mirrors AdenIntegrationInfo so local and Aden accounts can be treated
|
||||
uniformly in the credential tester and account selection UI.
|
||||
|
||||
Attributes:
|
||||
credential_id: The logical credential name (e.g. "brave_search", "github")
|
||||
alias: User-provided name for this account (e.g. "work", "personal")
|
||||
status: "active" | "failed" | "unknown"
|
||||
identity: Email, username, workspace, or account_id extracted from health check
|
||||
last_validated: When the key was last verified against the live API
|
||||
created_at: When this account was first stored
|
||||
"""
|
||||
|
||||
credential_id: str
|
||||
alias: str
|
||||
status: str = "unknown"
|
||||
identity: CredentialIdentity = field(default_factory=CredentialIdentity)
|
||||
last_validated: datetime | None = None
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
@property
|
||||
def storage_id(self) -> str:
|
||||
"""The key used in EncryptedFileStorage: '{credential_id}/{alias}'."""
|
||||
return f"{self.credential_id}/{self.alias}"
|
||||
|
||||
def to_account_dict(self) -> dict:
|
||||
"""
|
||||
Format compatible with AccountSelectionScreen and configure_for_account().
|
||||
|
||||
Same shape as Aden account dicts, with source='local' added.
|
||||
"""
|
||||
return {
|
||||
"provider": self.credential_id,
|
||||
"alias": self.alias,
|
||||
"identity": self.identity.to_dict(),
|
||||
"integration_id": None,
|
||||
"source": "local",
|
||||
"status": self.status,
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Local Credential Registry.
|
||||
|
||||
Manages named local API key accounts stored in EncryptedFileStorage.
|
||||
Mirrors the Aden integration model so local credentials have feature parity:
|
||||
aliases, identity metadata, status tracking, CRUD, and health validation.
|
||||
|
||||
Storage convention:
|
||||
{credential_id}/{alias} → CredentialObject
|
||||
e.g. "brave_search/work" → { api_key: "BSA-xxx", _alias: "work",
|
||||
_integration_type: "brave_search",
|
||||
_status: "active",
|
||||
_identity_username: "acme", ... }
|
||||
|
||||
Usage:
|
||||
registry = LocalCredentialRegistry.default()
|
||||
|
||||
# Add a new account
|
||||
info, health = registry.save_account("brave_search", "work", "BSA-xxx")
|
||||
print(info.status, info.identity.label)
|
||||
|
||||
# List all accounts
|
||||
for account in registry.list_accounts():
|
||||
print(f"{account.credential_id}/{account.alias}: {account.status}")
|
||||
|
||||
# Get the raw API key for a specific account
|
||||
key = registry.get_key("github", "personal")
|
||||
|
||||
# Re-validate a stored account
|
||||
result = registry.validate_account("github", "personal")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from framework.credentials.models import CredentialIdentity, CredentialObject
|
||||
from framework.credentials.storage import EncryptedFileStorage
|
||||
|
||||
from .models import LocalAccountInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aden_tools.credentials.health_check import HealthCheckResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEPARATOR = "/"
|
||||
|
||||
|
||||
class LocalCredentialRegistry:
|
||||
"""
|
||||
Named local API key account store backed by EncryptedFileStorage.
|
||||
|
||||
Provides the same list/save/get/delete/validate surface as the Aden
|
||||
client, but for locally-stored API keys.
|
||||
"""
|
||||
|
||||
def __init__(self, storage: EncryptedFileStorage) -> None:
|
||||
self._storage = storage
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Listing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_accounts(self, credential_id: str | None = None) -> list[LocalAccountInfo]:
|
||||
"""
|
||||
List all stored local accounts.
|
||||
|
||||
Args:
|
||||
credential_id: If given, filter to this credential type only.
|
||||
|
||||
Returns:
|
||||
List of LocalAccountInfo sorted by credential_id then alias.
|
||||
"""
|
||||
all_ids = self._storage.list_all()
|
||||
accounts: list[LocalAccountInfo] = []
|
||||
|
||||
for storage_id in all_ids:
|
||||
if _SEPARATOR not in storage_id:
|
||||
continue # Skip legacy un-aliased entries
|
||||
|
||||
try:
|
||||
cred_obj = self._storage.load(storage_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Skipping unreadable credential %s: %s", storage_id, exc)
|
||||
continue
|
||||
|
||||
if cred_obj is None:
|
||||
continue
|
||||
|
||||
info = self._to_account_info(cred_obj)
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
if credential_id and info.credential_id != credential_id:
|
||||
continue
|
||||
|
||||
accounts.append(info)
|
||||
|
||||
return sorted(accounts, key=lambda a: (a.credential_id, a.alias))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Save / add
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save_account(
|
||||
self,
|
||||
credential_id: str,
|
||||
alias: str,
|
||||
api_key: str,
|
||||
run_health_check: bool = True,
|
||||
extra_keys: dict[str, str] | None = None,
|
||||
) -> tuple[LocalAccountInfo, HealthCheckResult | None]:
|
||||
"""
|
||||
Store a named account, optionally validating it first.
|
||||
|
||||
Args:
|
||||
credential_id: Logical credential name (e.g. "brave_search").
|
||||
alias: User-chosen name (e.g. "work"). Defaults to "default".
|
||||
api_key: The raw API key / token value.
|
||||
run_health_check: If True, verify the key against the live API
|
||||
and extract identity metadata. Failure still saves with
|
||||
status="failed" so the user can re-validate later.
|
||||
extra_keys: Additional key/value pairs to store (e.g.
|
||||
cse_id for google_custom_search).
|
||||
|
||||
Returns:
|
||||
(LocalAccountInfo, HealthCheckResult | None)
|
||||
"""
|
||||
alias = alias or "default"
|
||||
health_result: HealthCheckResult | None = None
|
||||
identity: dict[str, str] = {}
|
||||
status = "active"
|
||||
|
||||
if run_health_check:
|
||||
try:
|
||||
from aden_tools.credentials.health_check import check_credential_health
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
if extra_keys and "cse_id" in extra_keys:
|
||||
kwargs["cse_id"] = extra_keys["cse_id"]
|
||||
|
||||
health_result = check_credential_health(credential_id, api_key, **kwargs)
|
||||
status = "active" if health_result.valid else "failed"
|
||||
identity = health_result.details.get("identity", {})
|
||||
except Exception as exc:
|
||||
logger.warning("Health check failed for %s/%s: %s", credential_id, alias, exc)
|
||||
status = "unknown"
|
||||
|
||||
storage_id = f"{credential_id}{_SEPARATOR}{alias}"
|
||||
now = datetime.now(UTC)
|
||||
|
||||
cred_obj = CredentialObject(id=storage_id)
|
||||
cred_obj.set_key("api_key", api_key)
|
||||
cred_obj.set_key("_alias", alias)
|
||||
cred_obj.set_key("_integration_type", credential_id)
|
||||
cred_obj.set_key("_status", status)
|
||||
|
||||
if extra_keys:
|
||||
for k, v in extra_keys.items():
|
||||
cred_obj.set_key(k, v)
|
||||
|
||||
if identity:
|
||||
valid_fields = set(CredentialIdentity.model_fields)
|
||||
filtered = {k: v for k, v in identity.items() if k in valid_fields}
|
||||
if filtered:
|
||||
cred_obj.set_identity(**filtered)
|
||||
|
||||
cred_obj.last_refreshed = now if run_health_check else None
|
||||
self._storage.save(cred_obj)
|
||||
|
||||
account_info = LocalAccountInfo(
|
||||
credential_id=credential_id,
|
||||
alias=alias,
|
||||
status=status,
|
||||
identity=cred_obj.identity,
|
||||
last_validated=cred_obj.last_refreshed,
|
||||
created_at=cred_obj.created_at,
|
||||
)
|
||||
return account_info, health_result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Get
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_account(self, credential_id: str, alias: str) -> CredentialObject | None:
|
||||
"""Load the raw CredentialObject for a specific account."""
|
||||
return self._storage.load(f"{credential_id}{_SEPARATOR}{alias}")
|
||||
|
||||
def get_key(self, credential_id: str, alias: str, key_name: str = "api_key") -> str | None:
|
||||
"""
|
||||
Return the stored secret value for a specific account.
|
||||
|
||||
Args:
|
||||
credential_id: Logical credential name (e.g. "brave_search").
|
||||
alias: Account alias (e.g. "work").
|
||||
key_name: Key within the credential (default "api_key").
|
||||
|
||||
Returns:
|
||||
The secret value, or None if not found.
|
||||
"""
|
||||
cred = self.get_account(credential_id, alias)
|
||||
if cred is None:
|
||||
return None
|
||||
return cred.get_key(key_name)
|
||||
|
||||
def get_account_info(self, credential_id: str, alias: str) -> LocalAccountInfo | None:
|
||||
"""Load a LocalAccountInfo for a specific account."""
|
||||
cred = self.get_account(credential_id, alias)
|
||||
if cred is None:
|
||||
return None
|
||||
return self._to_account_info(cred)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Delete
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def delete_account(self, credential_id: str, alias: str) -> bool:
|
||||
"""
|
||||
Remove a stored account.
|
||||
|
||||
Returns:
|
||||
True if the account existed and was deleted, False otherwise.
|
||||
"""
|
||||
return self._storage.delete(f"{credential_id}{_SEPARATOR}{alias}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validate
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def validate_account(self, credential_id: str, alias: str) -> HealthCheckResult:
|
||||
"""
|
||||
Re-run health check for a stored account and update its status.
|
||||
|
||||
Args:
|
||||
credential_id: Logical credential name.
|
||||
alias: Account alias.
|
||||
|
||||
Returns:
|
||||
HealthCheckResult from the live API check.
|
||||
|
||||
Raises:
|
||||
KeyError: If the account doesn't exist.
|
||||
"""
|
||||
from aden_tools.credentials.health_check import HealthCheckResult, check_credential_health
|
||||
|
||||
cred = self.get_account(credential_id, alias)
|
||||
if cred is None:
|
||||
raise KeyError(f"No local account found: {credential_id}/{alias}")
|
||||
|
||||
api_key = cred.get_key("api_key")
|
||||
if not api_key:
|
||||
return HealthCheckResult(valid=False, message="No api_key stored for this account")
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
cse_id = cred.get_key("cse_id")
|
||||
if cse_id:
|
||||
kwargs["cse_id"] = cse_id
|
||||
|
||||
result = check_credential_health(credential_id, api_key, **kwargs)
|
||||
except Exception as exc:
|
||||
result = HealthCheckResult(
|
||||
valid=False,
|
||||
message=f"Health check error: {exc}",
|
||||
details={"error": str(exc)},
|
||||
)
|
||||
|
||||
# Update status and timestamp in-place
|
||||
new_status = "active" if result.valid else "failed"
|
||||
cred.set_key("_status", new_status)
|
||||
cred.last_refreshed = datetime.now(UTC)
|
||||
|
||||
# Re-extract identity if available
|
||||
identity = result.details.get("identity", {})
|
||||
if identity:
|
||||
valid_fields = set(CredentialIdentity.model_fields)
|
||||
filtered = {k: v for k, v in identity.items() if k in valid_fields}
|
||||
if filtered:
|
||||
cred.set_identity(**filtered)
|
||||
|
||||
self._storage.save(cred)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> LocalCredentialRegistry:
|
||||
"""Create a registry using the default encrypted storage at ~/.hive/credentials."""
|
||||
return cls(EncryptedFileStorage())
|
||||
|
||||
@classmethod
|
||||
def at_path(cls, path: str | Path) -> LocalCredentialRegistry:
|
||||
"""Create a registry using a custom storage path."""
|
||||
return cls(EncryptedFileStorage(base_path=path))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _to_account_info(self, cred_obj: CredentialObject) -> LocalAccountInfo | None:
|
||||
"""Build LocalAccountInfo from a CredentialObject."""
|
||||
cred_type_key = cred_obj.keys.get("_integration_type")
|
||||
if cred_type_key is None:
|
||||
return None
|
||||
cred_id = cred_type_key.get_secret_value()
|
||||
|
||||
alias_key = cred_obj.keys.get("_alias")
|
||||
alias = alias_key.get_secret_value() if alias_key else cred_obj.id.split(_SEPARATOR, 1)[-1]
|
||||
|
||||
status_key = cred_obj.keys.get("_status")
|
||||
status = status_key.get_secret_value() if status_key else "unknown"
|
||||
|
||||
return LocalAccountInfo(
|
||||
credential_id=cred_id,
|
||||
alias=alias,
|
||||
status=status,
|
||||
identity=cred_obj.identity,
|
||||
last_validated=cred_obj.last_refreshed,
|
||||
created_at=cred_obj.created_at,
|
||||
)
|
||||
@@ -14,20 +14,36 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_credential_key_env() -> None:
|
||||
"""Load HIVE_CREDENTIAL_KEY and ADEN_API_KEY from shell config if not in environment.
|
||||
"""Load credentials from shell config if not in environment.
|
||||
|
||||
The setup-credentials skill writes these to ~/.zshrc or ~/.bashrc.
|
||||
If the user hasn't sourced their config in the current shell, this reads
|
||||
them directly so the runner (and any MCP subprocesses it spawns) can:
|
||||
- Unlock the encrypted credential store (HIVE_CREDENTIAL_KEY)
|
||||
- Enable Aden OAuth sync for Google/HubSpot/etc. (ADEN_API_KEY)
|
||||
The quickstart.sh and setup-credentials skill write API keys to ~/.zshrc
|
||||
or ~/.bashrc. If the user hasn't sourced their config in the current shell,
|
||||
this reads them directly so the runner (and any MCP subprocesses) can use them.
|
||||
|
||||
Loads:
|
||||
- HIVE_CREDENTIAL_KEY (encrypted credential store)
|
||||
- ADEN_API_KEY (Aden OAuth sync)
|
||||
- All LLM API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, ZAI_API_KEY, etc.)
|
||||
"""
|
||||
try:
|
||||
from aden_tools.credentials.shell_config import check_env_var_in_shell_config
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
for var_name in ("HIVE_CREDENTIAL_KEY", "ADEN_API_KEY"):
|
||||
# Core credentials that are always checked
|
||||
env_vars_to_load = ["HIVE_CREDENTIAL_KEY", "ADEN_API_KEY"]
|
||||
|
||||
# Add all LLM/tool API keys from CREDENTIAL_SPECS
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
for spec in CREDENTIAL_SPECS.values():
|
||||
if spec.env_var and spec.env_var not in env_vars_to_load:
|
||||
env_vars_to_load.append(spec.env_var)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
for var_name in env_vars_to_load:
|
||||
if os.environ.get(var_name):
|
||||
continue
|
||||
found, value = check_env_var_in_shell_config(var_name)
|
||||
|
||||
@@ -315,8 +315,10 @@ class EventLoopNode(NodeProtocol):
|
||||
f"{system_prompt}\n\n"
|
||||
f"--- Your Memory ---\n{_adapt_text}\n--- End Memory ---\n\n"
|
||||
'Maintain your memory by calling save_data("adapt.md", ...) '
|
||||
'or edit_data("adapt.md", ...) as you work. '
|
||||
"Record identity, session history, decisions, and working notes."
|
||||
'or edit_data("adapt.md", ...) as you work.\n'
|
||||
"IMMEDIATELY save: user rules about which account/identity to use, "
|
||||
"behavioral constraints, and preferences. "
|
||||
"Also record session history, decisions, and working notes."
|
||||
)
|
||||
|
||||
conversation = NodeConversation(
|
||||
@@ -2198,7 +2200,11 @@ class EventLoopNode(NodeProtocol):
|
||||
)
|
||||
prompt = (
|
||||
"Summarize this conversation so far in 2-3 sentences, "
|
||||
"preserving key decisions and results:\n\n"
|
||||
"preserving key decisions and results.\n\n"
|
||||
"IMPORTANT: Always preserve any user-stated rules, constraints, "
|
||||
"or preferences — especially which account/identity to use, "
|
||||
"formatting preferences, and behavioral instructions. "
|
||||
"These MUST appear verbatim or near-verbatim in your summary.\n\n"
|
||||
f"{messages_text}"
|
||||
)
|
||||
if tool_history:
|
||||
@@ -2215,7 +2221,9 @@ class EventLoopNode(NodeProtocol):
|
||||
response = await ctx.llm.acomplete(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
system=(
|
||||
"Summarize conversations concisely. Always preserve the tool history section."
|
||||
"Summarize conversations concisely. Always preserve the tool "
|
||||
"history section. Always preserve user-stated rules, constraints, "
|
||||
"and account/identity preferences verbatim."
|
||||
),
|
||||
max_tokens=summary_budget,
|
||||
)
|
||||
@@ -2287,13 +2295,24 @@ class EventLoopNode(NodeProtocol):
|
||||
|
||||
# 5. Spillover files — list actual files so the LLM can load
|
||||
# them immediately instead of having to call list_data_files first.
|
||||
# Inline adapt.md (agent memory) directly — it contains user rules
|
||||
# and identity preferences that must survive emergency compaction.
|
||||
if self._config.spillover_dir:
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
data_dir = Path(self._config.spillover_dir)
|
||||
if data_dir.is_dir():
|
||||
files = sorted(f.name for f in data_dir.iterdir() if f.is_file())
|
||||
# Inline adapt.md content directly
|
||||
adapt_path = data_dir / "adapt.md"
|
||||
if adapt_path.is_file():
|
||||
adapt_text = adapt_path.read_text(encoding="utf-8").strip()
|
||||
if adapt_text:
|
||||
parts.append(f"AGENT MEMORY (adapt.md):\n{adapt_text}")
|
||||
|
||||
files = sorted(
|
||||
f.name for f in data_dir.iterdir() if f.is_file() and f.name != "adapt.md"
|
||||
)
|
||||
if files:
|
||||
file_list = "\n".join(f" - {f}" for f in files[:30])
|
||||
parts.append("DATA FILES (use load_data to read):\n" + file_list)
|
||||
|
||||
@@ -135,6 +135,8 @@ class GraphExecutor:
|
||||
storage_path: str | Path | None = None,
|
||||
loop_config: dict[str, Any] | None = None,
|
||||
accounts_prompt: str = "",
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the executor.
|
||||
@@ -155,6 +157,8 @@ class GraphExecutor:
|
||||
storage_path: Optional base path for conversation persistence
|
||||
loop_config: Optional EventLoopNode configuration (max_iterations, etc.)
|
||||
accounts_prompt: Connected accounts block for system prompt injection
|
||||
accounts_data: Raw account data for per-node prompt generation
|
||||
tool_provider_map: Tool name to provider name mapping for account routing
|
||||
"""
|
||||
self.runtime = runtime
|
||||
self.llm = llm
|
||||
@@ -170,6 +174,8 @@ class GraphExecutor:
|
||||
self._storage_path = Path(storage_path) if storage_path else None
|
||||
self._loop_config = loop_config or {}
|
||||
self.accounts_prompt = accounts_prompt
|
||||
self.accounts_data = accounts_data
|
||||
self.tool_provider_map = tool_provider_map
|
||||
|
||||
# Initialize output cleaner
|
||||
self.cleansing_config = cleansing_config or CleansingConfig()
|
||||
@@ -1166,6 +1172,7 @@ class GraphExecutor:
|
||||
next_spec = graph.get_node(current_node_id)
|
||||
if next_spec and next_spec.node_type == "event_loop":
|
||||
from framework.graph.prompt_composer import (
|
||||
build_accounts_prompt,
|
||||
build_narrative,
|
||||
build_transition_marker,
|
||||
compose_system_prompt,
|
||||
@@ -1191,12 +1198,24 @@ class GraphExecutor:
|
||||
else _adapt_text
|
||||
)
|
||||
|
||||
# Build per-node accounts prompt for the next node
|
||||
_node_accounts = self.accounts_prompt or None
|
||||
if self.accounts_data and self.tool_provider_map:
|
||||
_node_accounts = (
|
||||
build_accounts_prompt(
|
||||
self.accounts_data,
|
||||
self.tool_provider_map,
|
||||
node_tool_names=next_spec.tools,
|
||||
)
|
||||
or None
|
||||
)
|
||||
|
||||
# Compose new system prompt (Layer 1 + 2 + 3 + accounts)
|
||||
new_system = compose_system_prompt(
|
||||
identity_prompt=getattr(graph, "identity_prompt", None),
|
||||
focus_prompt=next_spec.system_prompt,
|
||||
narrative=narrative,
|
||||
accounts_prompt=self.accounts_prompt or None,
|
||||
accounts_prompt=_node_accounts,
|
||||
)
|
||||
continuous_conversation.update_system_prompt(new_system)
|
||||
|
||||
@@ -1523,6 +1542,17 @@ class GraphExecutor:
|
||||
write_keys=node_spec.output_keys,
|
||||
)
|
||||
|
||||
# Build per-node accounts prompt (filtered to this node's tools)
|
||||
node_accounts_prompt = self.accounts_prompt
|
||||
if self.accounts_data and self.tool_provider_map:
|
||||
from framework.graph.prompt_composer import build_accounts_prompt
|
||||
|
||||
node_accounts_prompt = build_accounts_prompt(
|
||||
self.accounts_data,
|
||||
self.tool_provider_map,
|
||||
node_tool_names=node_spec.tools,
|
||||
)
|
||||
|
||||
return NodeContext(
|
||||
runtime=self.runtime,
|
||||
node_id=node_spec.id,
|
||||
@@ -1540,7 +1570,7 @@ class GraphExecutor:
|
||||
inherited_conversation=inherited_conversation,
|
||||
cumulative_output_keys=cumulative_output_keys or [],
|
||||
event_triggered=event_triggered,
|
||||
accounts_prompt=self.accounts_prompt,
|
||||
accounts_prompt=node_accounts_prompt,
|
||||
execution_id=self.runtime.execution_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ class SuccessCriterion(BaseModel):
|
||||
metric: str = Field(
|
||||
description="How to measure: 'output_contains', 'output_equals', 'llm_judge', 'custom'"
|
||||
)
|
||||
# NEW: runtime evaluation type (separate from metric)
|
||||
type: str = Field(
|
||||
default="success_rate", description="Runtime evaluation type, e.g. 'success_rate'"
|
||||
)
|
||||
|
||||
target: Any = Field(description="The target value or condition")
|
||||
weight: float = Field(default=1.0, ge=0.0, le=1.0, description="Relative importance (0-1)")
|
||||
met: bool = False
|
||||
|
||||
@@ -34,29 +34,105 @@ def _with_datetime(prompt: str) -> str:
|
||||
return f"{prompt}\n\n{stamp}" if prompt else stamp
|
||||
|
||||
|
||||
def build_accounts_prompt(accounts: list[dict[str, Any]]) -> str:
|
||||
def build_accounts_prompt(
|
||||
accounts: list[dict[str, Any]],
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
node_tool_names: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Build a prompt section describing connected accounts.
|
||||
|
||||
When tool_provider_map is provided, produces structured output grouped
|
||||
by provider with tool mapping, so the LLM knows which ``account`` value
|
||||
to pass to which tool.
|
||||
|
||||
When node_tool_names is also provided, filters to only show providers
|
||||
whose tools overlap with the node's tool list.
|
||||
|
||||
Args:
|
||||
accounts: List of account info dicts from CredentialStoreAdapter.get_all_account_info().
|
||||
accounts: List of account info dicts from
|
||||
CredentialStoreAdapter.get_all_account_info().
|
||||
tool_provider_map: Mapping of tool_name -> provider_name
|
||||
(e.g. {"gmail_list_messages": "google"}).
|
||||
node_tool_names: Tool names available to the current node.
|
||||
When provided, only providers with matching tools are shown.
|
||||
|
||||
Returns:
|
||||
Formatted accounts block, or empty string if no accounts.
|
||||
"""
|
||||
if not accounts:
|
||||
return ""
|
||||
lines = [
|
||||
"Connected accounts (use the alias as the `account` parameter "
|
||||
"when calling tools to target a specific account):"
|
||||
]
|
||||
|
||||
# Flat format (backward compat) when no tool mapping provided
|
||||
if tool_provider_map is None:
|
||||
lines = [
|
||||
"Connected accounts (use the alias as the `account` parameter "
|
||||
"when calling tools to target a specific account):"
|
||||
]
|
||||
for acct in accounts:
|
||||
provider = acct.get("provider", "unknown")
|
||||
alias = acct.get("alias", "unknown")
|
||||
identity = acct.get("identity", {})
|
||||
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
lines.append(f"- {provider}/{alias}{detail}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Structured format: group by provider with tool mapping ---
|
||||
|
||||
# Invert tool_provider_map to provider -> [tools]
|
||||
provider_tools: dict[str, list[str]] = {}
|
||||
for tool_name, provider in tool_provider_map.items():
|
||||
provider_tools.setdefault(provider, []).append(tool_name)
|
||||
|
||||
# Filter to relevant providers based on node tools
|
||||
node_tool_set = set(node_tool_names) if node_tool_names else None
|
||||
|
||||
# Group accounts by provider
|
||||
provider_accounts: dict[str, list[dict[str, Any]]] = {}
|
||||
for acct in accounts:
|
||||
provider = acct.get("provider", "unknown")
|
||||
alias = acct.get("alias", "unknown")
|
||||
identity = acct.get("identity", {})
|
||||
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
lines.append(f"- {provider}/{alias}{detail}")
|
||||
return "\n".join(lines)
|
||||
provider_accounts.setdefault(provider, []).append(acct)
|
||||
|
||||
sections: list[str] = ["Connected accounts:"]
|
||||
|
||||
for provider, acct_list in provider_accounts.items():
|
||||
tools_for_provider = sorted(provider_tools.get(provider, []))
|
||||
|
||||
# If node tools specified, only show providers with overlapping tools
|
||||
if node_tool_set is not None:
|
||||
relevant_tools = [t for t in tools_for_provider if t in node_tool_set]
|
||||
if not relevant_tools:
|
||||
continue
|
||||
tools_for_provider = relevant_tools
|
||||
|
||||
# Local-only providers: tools read from env vars, no account= routing
|
||||
all_local = all(a.get("source") == "local" for a in acct_list)
|
||||
|
||||
# Provider header with tools
|
||||
display_name = provider.replace("_", " ").title()
|
||||
if tools_for_provider and not all_local:
|
||||
tools_str = ", ".join(tools_for_provider)
|
||||
sections.append(f'\n{display_name} (use account="<alias>" with: {tools_str}):')
|
||||
elif tools_for_provider and all_local:
|
||||
tools_str = ", ".join(tools_for_provider)
|
||||
sections.append(f"\n{display_name} (tools: {tools_str}):")
|
||||
else:
|
||||
sections.append(f"\n{display_name}:")
|
||||
|
||||
# Account entries
|
||||
for acct in acct_list:
|
||||
alias = acct.get("alias", "unknown")
|
||||
identity = acct.get("identity", {})
|
||||
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
source_tag = " [local]" if acct.get("source") == "local" else ""
|
||||
sections.append(f" - {provider}/{alias}{detail}{source_tag}")
|
||||
|
||||
# If filtering removed all providers, return empty
|
||||
if len(sections) <= 1:
|
||||
return ""
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
def compose_system_prompt(
|
||||
|
||||
@@ -3338,6 +3338,11 @@ def store_credential(
|
||||
str, "Logical credential name (e.g., 'hubspot', 'brave_search', 'anthropic')"
|
||||
],
|
||||
credential_value: Annotated[str, "The secret value to store (API key, token, etc.)"],
|
||||
alias: Annotated[
|
||||
str,
|
||||
"Named alias for this account (e.g., 'work', 'personal'). Defaults to 'default'. "
|
||||
"Use aliases to store multiple accounts for the same service.",
|
||||
] = "default",
|
||||
key_name: Annotated[
|
||||
str, "Key name within the credential (e.g., 'api_key', 'access_token')"
|
||||
] = "api_key",
|
||||
@@ -3347,38 +3352,42 @@ def store_credential(
|
||||
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.
|
||||
|
||||
Credentials are stored as {credential_name}/{alias}, allowing multiple named accounts
|
||||
per service (e.g., 'brave_search/work', 'brave_search/personal').
|
||||
A health check is run automatically to validate the key and extract identity metadata.
|
||||
"""
|
||||
try:
|
||||
from pydantic import SecretStr
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
from framework.credentials import CredentialKey, CredentialObject
|
||||
|
||||
store = _get_credential_store()
|
||||
|
||||
if not display_name:
|
||||
display_name = credential_name.replace("_", " ").title()
|
||||
|
||||
cred = CredentialObject(
|
||||
id=credential_name,
|
||||
name=display_name,
|
||||
keys={
|
||||
key_name: CredentialKey(
|
||||
name=key_name,
|
||||
value=SecretStr(credential_value),
|
||||
)
|
||||
},
|
||||
registry = LocalCredentialRegistry.default()
|
||||
info, health_result = registry.save_account(
|
||||
credential_id=credential_name,
|
||||
alias=alias,
|
||||
api_key=credential_value,
|
||||
run_health_check=True,
|
||||
)
|
||||
store.save_credential(cred)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"credential": credential_name,
|
||||
"key": key_name,
|
||||
"location": "~/.hive/credentials",
|
||||
"encrypted": True,
|
||||
result: dict = {
|
||||
"success": True,
|
||||
"credential": credential_name,
|
||||
"alias": alias,
|
||||
"storage_id": info.storage_id,
|
||||
"status": info.status,
|
||||
"location": "~/.hive/credentials",
|
||||
"encrypted": True,
|
||||
}
|
||||
|
||||
if health_result is not None:
|
||||
result["health_check"] = {
|
||||
"valid": health_result.valid,
|
||||
"message": health_result.message,
|
||||
}
|
||||
)
|
||||
identity = info.identity.to_dict()
|
||||
if identity:
|
||||
result["identity"] = identity
|
||||
|
||||
return json.dumps(result)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
@@ -3388,26 +3397,28 @@ def list_stored_credentials() -> str:
|
||||
"""
|
||||
List all credentials currently stored in the local encrypted store.
|
||||
|
||||
Returns credential IDs and metadata (never returns secret values).
|
||||
Returns credential IDs, aliases, status, and identity metadata (never returns secret values).
|
||||
"""
|
||||
try:
|
||||
store = _get_credential_store()
|
||||
credential_ids = store.list_credentials()
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
accounts = registry.list_accounts()
|
||||
|
||||
credentials = []
|
||||
for cred_id in credential_ids:
|
||||
try:
|
||||
cred = store.get_credential(cred_id)
|
||||
credentials.append(
|
||||
{
|
||||
"id": cred.id,
|
||||
"name": cred.name,
|
||||
"keys": list(cred.keys.keys()),
|
||||
"created_at": cred.created_at.isoformat() if cred.created_at else None,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
credentials.append({"id": cred_id, "error": "Could not load"})
|
||||
for info in accounts:
|
||||
entry: dict = {
|
||||
"credential_id": info.credential_id,
|
||||
"alias": info.alias,
|
||||
"storage_id": info.storage_id,
|
||||
"status": info.status,
|
||||
"created_at": info.created_at.isoformat() if info.created_at else None,
|
||||
"last_validated": info.last_validated.isoformat() if info.last_validated else None,
|
||||
}
|
||||
identity = info.identity.to_dict()
|
||||
if identity:
|
||||
entry["identity"] = identity
|
||||
credentials.append(entry)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -3424,26 +3435,75 @@ def list_stored_credentials() -> str:
|
||||
@mcp.tool()
|
||||
def delete_stored_credential(
|
||||
credential_name: Annotated[str, "Logical credential name to delete (e.g., 'hubspot')"],
|
||||
alias: Annotated[
|
||||
str,
|
||||
"Alias of the account to delete (e.g., 'work', 'personal'). Defaults to 'default'.",
|
||||
] = "default",
|
||||
) -> str:
|
||||
"""
|
||||
Delete a credential from the local encrypted store.
|
||||
"""
|
||||
try:
|
||||
store = _get_credential_store()
|
||||
deleted = store.delete_credential(credential_name)
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
storage_id = f"{credential_name}/{alias}"
|
||||
deleted = registry.delete_account(credential_name, alias)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": deleted,
|
||||
"credential": credential_name,
|
||||
"message": f"Credential '{credential_name}' deleted"
|
||||
"alias": alias,
|
||||
"storage_id": storage_id,
|
||||
"message": f"Credential '{storage_id}' deleted"
|
||||
if deleted
|
||||
else f"Credential '{credential_name}' not found",
|
||||
else f"Credential '{storage_id}' not found",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def validate_credential(
|
||||
credential_name: Annotated[
|
||||
str, "Logical credential name to validate (e.g., 'brave_search', 'github')"
|
||||
],
|
||||
alias: Annotated[
|
||||
str,
|
||||
"Alias of the account to validate (e.g., 'work', 'personal'). Defaults to 'default'.",
|
||||
] = "default",
|
||||
) -> str:
|
||||
"""
|
||||
Re-run health check for a stored credential and update its status.
|
||||
|
||||
Makes a live API call to verify the credential is still valid and updates
|
||||
the stored status and last_validated timestamp.
|
||||
"""
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
result = registry.validate_account(credential_name, alias)
|
||||
|
||||
response: dict = {
|
||||
"credential": credential_name,
|
||||
"alias": alias,
|
||||
"storage_id": f"{credential_name}/{alias}",
|
||||
"valid": result.valid,
|
||||
"status": "active" if result.valid else "failed",
|
||||
"message": result.message,
|
||||
}
|
||||
|
||||
identity = result.details.get("identity") if result.details else None
|
||||
if identity:
|
||||
response["identity"] = identity
|
||||
|
||||
return json.dumps(response)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def verify_credentials(
|
||||
agent_path: Annotated[str, "Path to the exported agent directory (e.g., 'exports/my-agent')"],
|
||||
|
||||
@@ -788,31 +788,38 @@ class AgentRunner:
|
||||
extra_headers={"authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
else:
|
||||
# Fall back to environment variable
|
||||
# First check api_key_env_var from config (set by quickstart)
|
||||
api_key_env = llm_config.get("api_key_env_var") or self._get_api_key_env_var(
|
||||
self.model
|
||||
)
|
||||
if api_key_env and os.environ.get(api_key_env):
|
||||
# Local models (e.g. Ollama) don't need an API key
|
||||
if self._is_local_model(self.model):
|
||||
self._llm = LiteLLMProvider(
|
||||
model=self.model,
|
||||
api_key=os.environ[api_key_env],
|
||||
api_base=api_base,
|
||||
)
|
||||
else:
|
||||
# Fall back to credential store
|
||||
api_key = self._get_api_key_from_credential_store()
|
||||
if api_key:
|
||||
# Fall back to environment variable
|
||||
# First check api_key_env_var from config (set by quickstart)
|
||||
api_key_env = llm_config.get("api_key_env_var") or self._get_api_key_env_var(
|
||||
self.model
|
||||
)
|
||||
if api_key_env and os.environ.get(api_key_env):
|
||||
self._llm = LiteLLMProvider(
|
||||
model=self.model, api_key=api_key, api_base=api_base
|
||||
model=self.model,
|
||||
api_key=os.environ[api_key_env],
|
||||
api_base=api_base,
|
||||
)
|
||||
# Set env var so downstream code (e.g. cleanup LLM in
|
||||
# node._extract_json) can also find it
|
||||
if api_key_env:
|
||||
os.environ[api_key_env] = api_key
|
||||
elif api_key_env:
|
||||
print(f"Warning: {api_key_env} not set. LLM calls will fail.")
|
||||
print(f"Set it with: export {api_key_env}=your-api-key")
|
||||
else:
|
||||
# Fall back to credential store
|
||||
api_key = self._get_api_key_from_credential_store()
|
||||
if api_key:
|
||||
self._llm = LiteLLMProvider(
|
||||
model=self.model, api_key=api_key, api_base=api_base
|
||||
)
|
||||
# Set env var so downstream code (e.g. cleanup LLM in
|
||||
# node._extract_json) can also find it
|
||||
if api_key_env:
|
||||
os.environ[api_key_env] = api_key
|
||||
elif api_key_env:
|
||||
print(f"Warning: {api_key_env} not set. LLM calls will fail.")
|
||||
print(f"Set it with: export {api_key_env}=your-api-key")
|
||||
|
||||
# Fail fast if the agent needs an LLM but none was configured
|
||||
if self._llm is None:
|
||||
@@ -820,6 +827,12 @@ class AgentRunner:
|
||||
if has_llm_nodes:
|
||||
from framework.credentials.models import CredentialError
|
||||
|
||||
if self._is_local_model(self.model):
|
||||
raise CredentialError(
|
||||
f"Failed to initialize LLM for local model '{self.model}'. "
|
||||
f"Ensure your local LLM server is running "
|
||||
f"(e.g. 'ollama serve' for Ollama)."
|
||||
)
|
||||
api_key_env = self._get_api_key_env_var(self.model)
|
||||
hint = (
|
||||
f"Set it with: export {api_key_env}=your-api-key"
|
||||
@@ -834,19 +847,28 @@ class AgentRunner:
|
||||
|
||||
# Collect connected account info for system prompt injection
|
||||
accounts_prompt = ""
|
||||
accounts_data: list[dict] | None = None
|
||||
tool_provider_map: dict[str, str] | None = None
|
||||
try:
|
||||
from aden_tools.credentials.store_adapter import CredentialStoreAdapter
|
||||
|
||||
adapter = CredentialStoreAdapter.default()
|
||||
accounts = adapter.get_all_account_info()
|
||||
if accounts:
|
||||
accounts_data = adapter.get_all_account_info()
|
||||
tool_provider_map = adapter.get_tool_provider_map()
|
||||
if accounts_data:
|
||||
from framework.graph.prompt_composer import build_accounts_prompt
|
||||
|
||||
accounts_prompt = build_accounts_prompt(accounts)
|
||||
accounts_prompt = build_accounts_prompt(accounts_data, tool_provider_map)
|
||||
except Exception:
|
||||
pass # Best-effort — agent works without account info
|
||||
|
||||
self._setup_agent_runtime(tools, tool_executor, accounts_prompt=accounts_prompt)
|
||||
self._setup_agent_runtime(
|
||||
tools,
|
||||
tool_executor,
|
||||
accounts_prompt=accounts_prompt,
|
||||
accounts_data=accounts_data,
|
||||
tool_provider_map=tool_provider_map,
|
||||
)
|
||||
|
||||
def _get_api_key_env_var(self, model: str) -> str | None:
|
||||
"""Get the environment variable name for the API key based on model name."""
|
||||
@@ -866,8 +888,8 @@ class AgentRunner:
|
||||
return "MISTRAL_API_KEY"
|
||||
elif model_lower.startswith("groq/"):
|
||||
return "GROQ_API_KEY"
|
||||
elif model_lower.startswith("ollama/"):
|
||||
return None # Ollama doesn't need an API key (local)
|
||||
elif self._is_local_model(model_lower):
|
||||
return None # Local models don't need an API key
|
||||
elif model_lower.startswith("azure/"):
|
||||
return "AZURE_API_KEY"
|
||||
elif model_lower.startswith("cohere/"):
|
||||
@@ -907,8 +929,29 @@ class AgentRunner:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_local_model(model: str) -> bool:
|
||||
"""Check if a model is a local model that doesn't require an API key.
|
||||
|
||||
Local providers like Ollama run on the user's machine and do not
|
||||
need any authentication credentials.
|
||||
"""
|
||||
LOCAL_PREFIXES = (
|
||||
"ollama/",
|
||||
"ollama_chat/",
|
||||
"vllm/",
|
||||
"lm_studio/",
|
||||
"llamacpp/",
|
||||
)
|
||||
return model.lower().startswith(LOCAL_PREFIXES)
|
||||
|
||||
def _setup_agent_runtime(
|
||||
self, tools: list, tool_executor: Callable | None, accounts_prompt: str = ""
|
||||
self,
|
||||
tools: list,
|
||||
tool_executor: Callable | None,
|
||||
accounts_prompt: str = "",
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Set up multi-entry-point execution using AgentRuntime."""
|
||||
# Convert AsyncEntryPointSpec to EntryPointSpec for AgentRuntime
|
||||
@@ -981,6 +1024,8 @@ class AgentRunner:
|
||||
config=runtime_config,
|
||||
graph_id=self.graph.id or self.agent_path.name,
|
||||
accounts_prompt=accounts_prompt,
|
||||
accounts_data=accounts_data,
|
||||
tool_provider_map=tool_provider_map,
|
||||
)
|
||||
|
||||
# Pass intro_message through for TUI display
|
||||
|
||||
@@ -127,6 +127,8 @@ class AgentRuntime:
|
||||
checkpoint_config: CheckpointConfig | None = None,
|
||||
graph_id: str | None = None,
|
||||
accounts_prompt: str = "",
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize agent runtime.
|
||||
@@ -143,12 +145,15 @@ class AgentRuntime:
|
||||
checkpoint_config: Optional checkpoint configuration for resumable sessions
|
||||
graph_id: Optional identifier for the primary graph (defaults to "primary")
|
||||
accounts_prompt: Connected accounts block for system prompt injection
|
||||
accounts_data: Raw account data for per-node prompt generation
|
||||
tool_provider_map: Tool name to provider name mapping for account routing
|
||||
"""
|
||||
self.graph = graph
|
||||
self.goal = goal
|
||||
self._config = config or AgentRuntimeConfig()
|
||||
self._runtime_log_store = runtime_log_store
|
||||
self._checkpoint_config = checkpoint_config
|
||||
self.accounts_prompt = accounts_prompt
|
||||
|
||||
# Primary graph identity
|
||||
self._graph_id: str = graph_id or "primary"
|
||||
@@ -181,6 +186,8 @@ class AgentRuntime:
|
||||
self._tools = tools or []
|
||||
self._tool_executor = tool_executor
|
||||
self._accounts_prompt = accounts_prompt
|
||||
self._accounts_data = accounts_data
|
||||
self._tool_provider_map = tool_provider_map
|
||||
|
||||
# Entry points and streams (primary graph)
|
||||
self._entry_points: dict[str, EntryPointSpec] = {}
|
||||
@@ -277,6 +284,8 @@ class AgentRuntime:
|
||||
checkpoint_config=self._checkpoint_config,
|
||||
graph_id=self._graph_id,
|
||||
accounts_prompt=self._accounts_prompt,
|
||||
accounts_data=self._accounts_data,
|
||||
tool_provider_map=self._tool_provider_map,
|
||||
)
|
||||
await stream.start()
|
||||
self._streams[ep_id] = stream
|
||||
@@ -679,6 +688,8 @@ class AgentRuntime:
|
||||
checkpoint_config=self._checkpoint_config,
|
||||
graph_id=graph_id,
|
||||
accounts_prompt=self._accounts_prompt,
|
||||
accounts_data=self._accounts_data,
|
||||
tool_provider_map=self._tool_provider_map,
|
||||
)
|
||||
if self._running:
|
||||
await stream.start()
|
||||
@@ -1153,6 +1164,8 @@ def create_agent_runtime(
|
||||
checkpoint_config: CheckpointConfig | None = None,
|
||||
graph_id: str | None = None,
|
||||
accounts_prompt: str = "",
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
) -> AgentRuntime:
|
||||
"""
|
||||
Create and configure an AgentRuntime with entry points.
|
||||
@@ -1176,6 +1189,8 @@ def create_agent_runtime(
|
||||
checkpoint_config: Optional checkpoint configuration for resumable sessions.
|
||||
If None, uses default checkpointing behavior.
|
||||
graph_id: Optional identifier for the primary graph (defaults to "primary").
|
||||
accounts_data: Raw account data for per-node prompt generation.
|
||||
tool_provider_map: Tool name to provider name mapping for account routing.
|
||||
|
||||
Returns:
|
||||
Configured AgentRuntime (not yet started)
|
||||
@@ -1199,6 +1214,8 @@ def create_agent_runtime(
|
||||
checkpoint_config=checkpoint_config,
|
||||
graph_id=graph_id,
|
||||
accounts_prompt=accounts_prompt,
|
||||
accounts_data=accounts_data,
|
||||
tool_provider_map=tool_provider_map,
|
||||
)
|
||||
|
||||
for spec in entry_points:
|
||||
|
||||
@@ -144,6 +144,8 @@ class ExecutionStream:
|
||||
checkpoint_config: CheckpointConfig | None = None,
|
||||
graph_id: str | None = None,
|
||||
accounts_prompt: str = "",
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize execution stream.
|
||||
@@ -165,6 +167,8 @@ class ExecutionStream:
|
||||
checkpoint_config: Optional checkpoint configuration for resumable sessions
|
||||
graph_id: Optional graph identifier for multi-graph sessions
|
||||
accounts_prompt: Connected accounts block for system prompt injection
|
||||
accounts_data: Raw account data for per-node prompt generation
|
||||
tool_provider_map: Tool name to provider name mapping for account routing
|
||||
"""
|
||||
self.stream_id = stream_id
|
||||
self.entry_spec = entry_spec
|
||||
@@ -184,6 +188,8 @@ class ExecutionStream:
|
||||
self._checkpoint_config = checkpoint_config
|
||||
self._session_store = session_store
|
||||
self._accounts_prompt = accounts_prompt
|
||||
self._accounts_data = accounts_data
|
||||
self._tool_provider_map = tool_provider_map
|
||||
|
||||
# Create stream-scoped runtime
|
||||
self._runtime = StreamRuntime(
|
||||
@@ -457,6 +463,8 @@ class ExecutionStream:
|
||||
runtime_logger=runtime_logger,
|
||||
loop_config=self.graph.loop_config,
|
||||
accounts_prompt=self._accounts_prompt,
|
||||
accounts_data=self._accounts_data,
|
||||
tool_provider_map=self._tool_provider_map,
|
||||
)
|
||||
# Track executor so inject_input() can reach EventLoopNode instances
|
||||
self._active_executors[execution_id] = executor
|
||||
|
||||
@@ -313,7 +313,6 @@ class OutcomeAggregator:
|
||||
async def _evaluate_criterion(self, criterion: Any) -> CriterionStatus:
|
||||
"""
|
||||
Evaluate a single success criterion.
|
||||
|
||||
This is a heuristic evaluation based on decision outcomes.
|
||||
More sophisticated evaluation can be added per criterion type.
|
||||
"""
|
||||
@@ -325,6 +324,11 @@ class OutcomeAggregator:
|
||||
evidence=[],
|
||||
)
|
||||
|
||||
# Guard: only apply this heuristic to success-rate criteria
|
||||
criterion_type = getattr(criterion, "type", "success_rate")
|
||||
if criterion_type != "success_rate":
|
||||
return status
|
||||
|
||||
# Get relevant decisions (those mentioning this criterion or related intents)
|
||||
relevant_decisions = [
|
||||
d
|
||||
@@ -341,13 +345,17 @@ class OutcomeAggregator:
|
||||
outcomes = [d.outcome for d in relevant_decisions if d.outcome is not None]
|
||||
if outcomes:
|
||||
success_count = sum(1 for o in outcomes if o.success)
|
||||
|
||||
# Progress is computed as raw success rate of decision outcomes.
|
||||
status.progress = success_count / len(outcomes)
|
||||
|
||||
# Add evidence
|
||||
for d in relevant_decisions[:5]: # Limit evidence
|
||||
if d.outcome:
|
||||
evidence = (
|
||||
f"{d.decision.intent}: {'success' if d.outcome.success else 'failed'}"
|
||||
f"decision_id={d.decision.id}, "
|
||||
f"intent={d.decision.intent}, "
|
||||
f"result={'success' if d.outcome.success else 'failed'}"
|
||||
)
|
||||
status.evidence.append(evidence)
|
||||
|
||||
|
||||
+66
-14
@@ -447,19 +447,6 @@ class AdenTUI(App):
|
||||
if runner._configure_for_account:
|
||||
runner._configure_for_account(runner, selected)
|
||||
|
||||
# Validate credentials for the now-scoped provider
|
||||
from framework.credentials.models import CredentialError as CredError
|
||||
from framework.credentials.validation import validate_agent_credentials
|
||||
|
||||
try:
|
||||
validate_agent_credentials(runner.graph.nodes)
|
||||
except CredError as e:
|
||||
self._show_credential_setup(
|
||||
str(runner.agent_path),
|
||||
credential_error=e,
|
||||
)
|
||||
return
|
||||
|
||||
# Continue with the rest of agent loading
|
||||
self._do_finish_agent_load(runner)
|
||||
|
||||
@@ -547,9 +534,74 @@ class AdenTUI(App):
|
||||
if result is None:
|
||||
self.exit()
|
||||
return
|
||||
self._handle_picker_result(result)
|
||||
|
||||
# Show Get Started tab on initial launch
|
||||
self.push_screen(
|
||||
AgentPickerScreen(agents, show_get_started=True),
|
||||
callback=_on_initial_pick,
|
||||
)
|
||||
|
||||
def _handle_picker_result(self, result: str) -> None:
|
||||
"""Handle the result from the agent picker, including Get Started actions."""
|
||||
if result.startswith("action:"):
|
||||
action = result.removeprefix("action:")
|
||||
if action == "run_examples":
|
||||
# Switch to Examples tab by re-opening picker focused on examples
|
||||
self._show_agent_picker_tab("examples")
|
||||
elif action == "run_existing":
|
||||
# Switch to Your Agents tab
|
||||
self._show_agent_picker_tab("your-agents")
|
||||
elif action == "build_edit":
|
||||
# Launch agent builder guidance
|
||||
self._show_build_edit_message()
|
||||
else:
|
||||
# Regular agent path - load it
|
||||
self._do_load_agent(result)
|
||||
|
||||
self.push_screen(AgentPickerScreen(agents), callback=_on_initial_pick)
|
||||
def _show_agent_picker_tab(self, tab_id: str) -> None:
|
||||
"""Show the agent picker focused on a specific tab (no Get Started)."""
|
||||
from framework.tui.screens.agent_picker import AgentPickerScreen, discover_agents
|
||||
|
||||
agents = discover_agents()
|
||||
if not agents:
|
||||
self.notify("No agents found", severity="error", timeout=5)
|
||||
return
|
||||
|
||||
def _on_pick(result: str | None) -> None:
|
||||
if result is None:
|
||||
self.exit()
|
||||
return
|
||||
if result.startswith("action:"):
|
||||
# Shouldn't happen but handle gracefully
|
||||
self._handle_picker_result(result)
|
||||
else:
|
||||
self._do_load_agent(result)
|
||||
|
||||
screen = AgentPickerScreen(agents, show_get_started=False)
|
||||
|
||||
def _focus_tab() -> None:
|
||||
try:
|
||||
tabbed = screen.query_one(
|
||||
"TabbedContent", expect_type=type(screen.query_one("TabbedContent"))
|
||||
)
|
||||
tabbed.active = tab_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.push_screen(screen, callback=_on_pick)
|
||||
self.call_later(_focus_tab)
|
||||
|
||||
def _show_build_edit_message(self) -> None:
|
||||
"""Show guidance for building or editing agents."""
|
||||
self.notify(
|
||||
"To build or edit agents, use 'hive build' from the terminal "
|
||||
"or run Claude Code with the /hive skill.",
|
||||
severity="information",
|
||||
timeout=10,
|
||||
)
|
||||
# Re-show picker so user can still select an agent
|
||||
self._show_agent_picker_initial()
|
||||
|
||||
def action_show_agent_picker(self) -> None:
|
||||
"""Open the agent picker (Ctrl+A or /agents)."""
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""TUI screens package."""
|
||||
|
||||
from .account_selection import AccountSelectionScreen
|
||||
from .add_local_credential import AddLocalCredentialScreen
|
||||
from .agent_picker import AgentPickerScreen
|
||||
from .credential_setup import CredentialSetupScreen
|
||||
|
||||
__all__ = [
|
||||
"AccountSelectionScreen",
|
||||
"AddLocalCredentialScreen",
|
||||
"AgentPickerScreen",
|
||||
"CredentialSetupScreen",
|
||||
]
|
||||
|
||||
@@ -66,16 +66,32 @@ class AccountSelectionScreen(ModalScreen[dict | None]):
|
||||
id="acct-subtitle",
|
||||
)
|
||||
option_list = OptionList(id="acct-list")
|
||||
for i, acct in enumerate(self._accounts):
|
||||
# Group: Aden accounts first, then local
|
||||
aden = [a for a in self._accounts if a.get("source") != "local"]
|
||||
local = [a for a in self._accounts if a.get("source") == "local"]
|
||||
ordered = aden + local
|
||||
for i, acct in enumerate(ordered):
|
||||
provider = acct.get("provider", "unknown")
|
||||
alias = acct.get("alias", "unknown")
|
||||
email = acct.get("identity", {}).get("email", "")
|
||||
identity = acct.get("identity", {})
|
||||
source = acct.get("source", "aden")
|
||||
# Build identity label: prefer email, then username/workspace
|
||||
identity_label = (
|
||||
identity.get("email")
|
||||
or identity.get("username")
|
||||
or identity.get("workspace")
|
||||
or ""
|
||||
)
|
||||
label = Text()
|
||||
label.append(f"{provider}/", style="bold")
|
||||
label.append(alias, style="bold cyan")
|
||||
if email:
|
||||
label.append(f" ({email})", style="dim")
|
||||
if source == "local":
|
||||
label.append(" [local]", style="dim yellow")
|
||||
if identity_label:
|
||||
label.append(f" ({identity_label})", style="dim")
|
||||
option_list.add_option(Option(label, id=f"acct-{i}"))
|
||||
# Keep ordered list for index lookups
|
||||
self._accounts = ordered
|
||||
yield option_list
|
||||
yield Label(
|
||||
"[dim]Enter[/dim] Select [dim]Esc[/dim] Cancel",
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Add Local Credential ModalScreen for storing named local API key accounts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical, VerticalScroll
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Input, Label, OptionList
|
||||
from textual.widgets._option_list import Option
|
||||
|
||||
|
||||
class AddLocalCredentialScreen(ModalScreen[dict | None]):
|
||||
"""Modal screen for adding a named local API key credential.
|
||||
|
||||
Phase 1: Pick credential type from list.
|
||||
Phase 2: Enter alias + API key, run health check, save.
|
||||
|
||||
Returns a dict with credential_id, alias, and identity on success, or None on cancel.
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "dismiss_screen", "Cancel"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
AddLocalCredentialScreen {
|
||||
align: center middle;
|
||||
}
|
||||
#alc-container {
|
||||
width: 80%;
|
||||
max-width: 90;
|
||||
height: 80%;
|
||||
background: $surface;
|
||||
border: heavy $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
#alc-title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
width: 100%;
|
||||
color: $text;
|
||||
}
|
||||
#alc-subtitle {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
#alc-type-list {
|
||||
height: 1fr;
|
||||
}
|
||||
#alc-form {
|
||||
height: 1fr;
|
||||
}
|
||||
.alc-field {
|
||||
margin-bottom: 1;
|
||||
height: auto;
|
||||
}
|
||||
.alc-field Label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#alc-status {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
padding: 1;
|
||||
background: $panel;
|
||||
}
|
||||
.alc-buttons {
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
align: center middle;
|
||||
}
|
||||
.alc-buttons Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
#alc-footer {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# Load credential specs that support direct API keys
|
||||
self._specs: list[tuple[str, object]] = self._load_specs()
|
||||
# Selected credential spec (set in phase 2)
|
||||
self._selected_id: str = ""
|
||||
self._selected_spec: object = None
|
||||
self._phase: int = 1 # 1 = type selection, 2 = form
|
||||
|
||||
@staticmethod
|
||||
def _load_specs() -> list[tuple[str, object]]:
|
||||
"""Return (credential_id, spec) pairs for direct-API-key credentials."""
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
return [
|
||||
(cid, spec)
|
||||
for cid, spec in CREDENTIAL_SPECS.items()
|
||||
if getattr(spec, "direct_api_key_supported", False)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Compose
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="alc-container"):
|
||||
yield Label("Add Local Credential", id="alc-title")
|
||||
yield Label("[dim]Store a named API key account[/dim]", id="alc-subtitle")
|
||||
# Phase 1: type selection
|
||||
option_list = OptionList(id="alc-type-list")
|
||||
for cid, spec in self._specs:
|
||||
description = getattr(spec, "description", cid)
|
||||
option_list.add_option(Option(f"{cid} [dim]{description}[/dim]", id=f"type-{cid}"))
|
||||
yield option_list
|
||||
# Phase 2: form (hidden initially)
|
||||
with VerticalScroll(id="alc-form"):
|
||||
with Vertical(classes="alc-field"):
|
||||
yield Label("[bold]Alias[/bold] [dim](e.g. work, personal)[/dim]")
|
||||
yield Input(value="default", id="alc-alias")
|
||||
with Vertical(classes="alc-field"):
|
||||
yield Label("[bold]API Key[/bold]")
|
||||
yield Input(placeholder="Paste API key...", password=True, id="alc-key")
|
||||
yield Label("", id="alc-status")
|
||||
with Vertical(classes="alc-buttons"):
|
||||
yield Button("Test & Save", variant="primary", id="btn-save")
|
||||
yield Button("Back", variant="default", id="btn-back")
|
||||
yield Label(
|
||||
"[dim]Enter[/dim] Select [dim]Esc[/dim] Cancel",
|
||||
id="alc-footer",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._show_phase(1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase switching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _show_phase(self, phase: int) -> None:
|
||||
self._phase = phase
|
||||
type_list = self.query_one("#alc-type-list", OptionList)
|
||||
form = self.query_one("#alc-form", VerticalScroll)
|
||||
if phase == 1:
|
||||
type_list.display = True
|
||||
form.display = False
|
||||
subtitle = self.query_one("#alc-subtitle", Label)
|
||||
subtitle.update("[dim]Select the credential type to add[/dim]")
|
||||
else:
|
||||
type_list.display = False
|
||||
form.display = True
|
||||
spec = self._selected_spec
|
||||
description = (
|
||||
getattr(spec, "description", self._selected_id) if spec else self._selected_id
|
||||
)
|
||||
subtitle = self.query_one("#alc-subtitle", Label)
|
||||
subtitle.update(f"[dim]{self._selected_id}[/dim] {description}")
|
||||
self._clear_status()
|
||||
# Focus the alias input
|
||||
self.query_one("#alc-alias", Input).focus()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
if self._phase != 1:
|
||||
return
|
||||
option_id = event.option.id or ""
|
||||
if option_id.startswith("type-"):
|
||||
cid = option_id[5:] # strip "type-" prefix
|
||||
self._selected_id = cid
|
||||
self._selected_spec = next(
|
||||
(spec for spec_id, spec in self._specs if spec_id == cid), None
|
||||
)
|
||||
self._show_phase(2)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-save":
|
||||
self._do_save()
|
||||
elif event.button.id == "btn-back":
|
||||
self._show_phase(1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Save logic
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _do_save(self) -> None:
|
||||
alias = self.query_one("#alc-alias", Input).value.strip() or "default"
|
||||
api_key = self.query_one("#alc-key", Input).value.strip()
|
||||
|
||||
if not api_key:
|
||||
self._set_status("[red]API key cannot be empty.[/red]")
|
||||
return
|
||||
|
||||
self._set_status("[dim]Running health check...[/dim]")
|
||||
# Disable save button while running
|
||||
btn = self.query_one("#btn-save", Button)
|
||||
btn.disabled = True
|
||||
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
info, health_result = registry.save_account(
|
||||
credential_id=self._selected_id,
|
||||
alias=alias,
|
||||
api_key=api_key,
|
||||
run_health_check=True,
|
||||
)
|
||||
|
||||
if health_result is not None and not health_result.valid:
|
||||
self._set_status(
|
||||
f"[yellow]Saved with failed health check:[/yellow] {health_result.message}\n"
|
||||
"[dim]You can re-validate later via validate_credential().[/dim]"
|
||||
)
|
||||
else:
|
||||
identity = info.identity.to_dict()
|
||||
identity_str = ""
|
||||
if identity:
|
||||
parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
identity_str = " " + ", ".join(parts) if parts else ""
|
||||
self._set_status(f"[green]Saved:[/green] {info.storage_id}{identity_str}")
|
||||
# Dismiss with result so callers can react
|
||||
self.set_timer(1.0, lambda: self.dismiss(info.to_account_dict()))
|
||||
return
|
||||
except Exception as e:
|
||||
self._set_status(f"[red]Error:[/red] {e}")
|
||||
finally:
|
||||
btn.disabled = False
|
||||
|
||||
def _set_status(self, markup: str) -> None:
|
||||
self.query_one("#alc-status", Label).update(markup)
|
||||
|
||||
def _clear_status(self) -> None:
|
||||
self.query_one("#alc-status", Label).update("")
|
||||
|
||||
def action_dismiss_screen(self) -> None:
|
||||
self.dismiss(None)
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Group
|
||||
@@ -16,6 +17,14 @@ from textual.widgets import Label, OptionList, TabbedContent, TabPane
|
||||
from textual.widgets._option_list import Option
|
||||
|
||||
|
||||
class GetStartedAction(Enum):
|
||||
"""Actions available in the Get Started tab."""
|
||||
|
||||
RUN_EXAMPLES = "run_examples"
|
||||
RUN_EXISTING = "run_existing"
|
||||
BUILD_EDIT = "build_edit"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentEntry:
|
||||
"""Lightweight agent metadata for the picker."""
|
||||
@@ -139,10 +148,20 @@ def _render_agent_option(agent: AgentEntry) -> Group:
|
||||
return Group(*parts)
|
||||
|
||||
|
||||
def _render_get_started_option(title: str, description: str, icon: str = "→") -> Group:
|
||||
"""Build a Rich renderable for a Get Started option."""
|
||||
line1 = Text()
|
||||
line1.append(f"{icon} ", style="bold cyan")
|
||||
line1.append(title, style="bold")
|
||||
line2 = Text(description, style="dim")
|
||||
return Group(line1, line2)
|
||||
|
||||
|
||||
class AgentPickerScreen(ModalScreen[str | None]):
|
||||
"""Modal screen showing available agents organized by tabbed categories.
|
||||
|
||||
Returns the selected agent path as a string, or None if dismissed.
|
||||
For Get Started actions, returns a special prefix like "action:run_examples".
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
@@ -188,9 +207,14 @@ class AgentPickerScreen(ModalScreen[str | None]):
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, agent_groups: dict[str, list[AgentEntry]]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
agent_groups: dict[str, list[AgentEntry]],
|
||||
show_get_started: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._groups = agent_groups
|
||||
self._show_get_started = show_get_started
|
||||
# Map (tab_id, option_index) -> AgentEntry
|
||||
self._option_map: dict[str, dict[int, AgentEntry]] = {}
|
||||
|
||||
@@ -203,6 +227,43 @@ class AgentPickerScreen(ModalScreen[str | None]):
|
||||
id="picker-subtitle",
|
||||
)
|
||||
with TabbedContent():
|
||||
# Get Started tab (only on initial launch)
|
||||
if self._show_get_started:
|
||||
with TabPane("Get Started", id="get-started"):
|
||||
option_list = OptionList(id="list-get-started")
|
||||
option_list.add_option(
|
||||
Option(
|
||||
_render_get_started_option(
|
||||
"Test and run example agents",
|
||||
"Try pre-built example agents to learn how Hive works",
|
||||
"📚",
|
||||
),
|
||||
id="action:run_examples",
|
||||
)
|
||||
)
|
||||
option_list.add_option(
|
||||
Option(
|
||||
_render_get_started_option(
|
||||
"Test and run existing agent",
|
||||
"Load and run an agent you've already built (from exports/)",
|
||||
"🚀",
|
||||
),
|
||||
id="action:run_existing",
|
||||
)
|
||||
)
|
||||
option_list.add_option(
|
||||
Option(
|
||||
_render_get_started_option(
|
||||
"Build or edit agent",
|
||||
"Create a new agent or modify an existing one",
|
||||
"🛠️ ",
|
||||
),
|
||||
id="action:build_edit",
|
||||
)
|
||||
)
|
||||
yield option_list
|
||||
|
||||
# Agent category tabs
|
||||
for category, agents in self._groups.items():
|
||||
tab_id = category.lower().replace(" ", "-")
|
||||
with TabPane(f"{category} ({len(agents)})", id=tab_id):
|
||||
@@ -224,6 +285,15 @@ class AgentPickerScreen(ModalScreen[str | None]):
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
list_id = event.option_list.id or ""
|
||||
|
||||
# Handle Get Started tab options
|
||||
if list_id == "list-get-started":
|
||||
option = event.option
|
||||
if option and option.id:
|
||||
self.dismiss(option.id) # Returns "action:run_examples", etc.
|
||||
return
|
||||
|
||||
# Handle agent selection from other tabs
|
||||
idx = event.option_index
|
||||
agent_map = self._option_map.get(list_id, {})
|
||||
agent = agent_map.get(idx)
|
||||
|
||||
@@ -27,9 +27,9 @@ from typing import Any
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Label, TextArea
|
||||
from textual.widgets import Button, Label, TextArea
|
||||
|
||||
from framework.runtime.agent_runtime import AgentRuntime
|
||||
from framework.runtime.event_bus import AgentEvent
|
||||
@@ -38,7 +38,7 @@ from framework.tui.widgets.selectable_rich_log import SelectableRichLog as RichL
|
||||
|
||||
|
||||
class ChatTextArea(TextArea):
|
||||
"""TextArea that submits on Enter and inserts newlines on Shift+Enter."""
|
||||
"""TextArea that submits on Enter and inserts newlines on Shift+Enter (or Ctrl+J)."""
|
||||
|
||||
class Submitted(Message):
|
||||
"""Posted when the user presses Enter."""
|
||||
@@ -55,7 +55,7 @@ class ChatTextArea(TextArea):
|
||||
self.post_message(self.Submitted(text))
|
||||
event.stop()
|
||||
event.prevent_default()
|
||||
elif event.key == "shift+enter":
|
||||
elif event.key in ("shift+enter", "ctrl+j"):
|
||||
event.key = "enter"
|
||||
await super()._on_key(event)
|
||||
else:
|
||||
@@ -72,6 +72,54 @@ class ChatRepl(Vertical):
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
ChatRepl > #input-row {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
dock: bottom;
|
||||
}
|
||||
|
||||
ChatRepl > #input-row > ChatTextArea {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
max-height: 7;
|
||||
dock: none;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
ChatRepl > #input-row > #action-button {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 10;
|
||||
margin-top: 1;
|
||||
margin-left: 1;
|
||||
border: none;
|
||||
dock: none;
|
||||
}
|
||||
|
||||
ChatRepl > #input-row > #action-button.send-mode {
|
||||
background: $success;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
ChatRepl > #input-row > #action-button.send-mode:hover {
|
||||
background: $success-darken-1;
|
||||
}
|
||||
|
||||
ChatRepl > #input-row > #action-button.pause-mode {
|
||||
background: red;
|
||||
color: white;
|
||||
}
|
||||
|
||||
ChatRepl > #input-row > #action-button.pause-mode:hover {
|
||||
background: darkred;
|
||||
}
|
||||
|
||||
ChatRepl > #input-row > #action-button:disabled {
|
||||
background: $panel;
|
||||
color: $text-muted;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
ChatRepl > RichLog {
|
||||
width: 100%;
|
||||
height: 1fr;
|
||||
@@ -104,17 +152,12 @@ class ChatRepl(Vertical):
|
||||
display: none;
|
||||
}
|
||||
|
||||
ChatRepl > ChatTextArea {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 7;
|
||||
dock: bottom;
|
||||
ChatRepl > #input-row > ChatTextArea {
|
||||
background: $surface;
|
||||
border: tall $primary;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
ChatRepl > ChatTextArea:focus {
|
||||
ChatRepl > #input-row > ChatTextArea:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
"""
|
||||
@@ -171,7 +214,9 @@ class ChatRepl(Vertical):
|
||||
min_width=0,
|
||||
)
|
||||
yield Label("Agent is processing...", id="processing-indicator")
|
||||
yield ChatTextArea(id="chat-input", placeholder="Enter input for agent...")
|
||||
with Horizontal(id="input-row"):
|
||||
yield ChatTextArea(id="chat-input", placeholder="Enter input for agent...")
|
||||
yield Button("↵ Send", id="action-button", disabled=True)
|
||||
|
||||
# Regex for file:// URIs that are NOT already inside Rich [link=...] markup
|
||||
_FILE_URI_RE = re.compile(r"(?<!\[link=)(file://[^\s)\]>*]+)")
|
||||
@@ -710,6 +755,8 @@ class ChatRepl(Vertical):
|
||||
f"[green]✓[/green] Resume started (execution: {exec_id[:12]}...)"
|
||||
)
|
||||
self._write_history(" Agent is continuing from where it stopped...")
|
||||
# Enable Pause button now that execution is running
|
||||
self._set_button_pause_mode()
|
||||
|
||||
except Exception as e:
|
||||
self._write_history(f"[bold red]Error starting resume:[/bold red] {e}")
|
||||
@@ -798,6 +845,8 @@ class ChatRepl(Vertical):
|
||||
f"[green]✓[/green] Recovery started (execution: {exec_id[:12]}...)"
|
||||
)
|
||||
self._write_history(" Agent is continuing from checkpoint...")
|
||||
# Enable Pause button now that execution is running
|
||||
self._set_button_pause_mode()
|
||||
|
||||
except Exception as e:
|
||||
self._write_history(f"[bold red]Error starting recovery:[/bold red] {e}")
|
||||
@@ -1087,10 +1136,71 @@ class ChatRepl(Vertical):
|
||||
# Silently fail - don't block TUI startup
|
||||
pass
|
||||
|
||||
def _set_button_send_mode(self) -> None:
|
||||
"""Switch the action button to Send mode (green arrow)."""
|
||||
try:
|
||||
btn = self.query_one("#action-button", Button)
|
||||
btn.label = "↵ Send"
|
||||
btn.disabled = False
|
||||
btn.remove_class("pause-mode")
|
||||
btn.add_class("send-mode")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _set_button_pause_mode(self) -> None:
|
||||
"""Switch the action button to Pause mode (red pause)."""
|
||||
try:
|
||||
btn = self.query_one("#action-button", Button)
|
||||
btn.label = "⏸ Pause"
|
||||
btn.disabled = False
|
||||
btn.remove_class("send-mode")
|
||||
btn.add_class("pause-mode")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _set_button_idle_mode(self) -> None:
|
||||
"""Switch the action button to idle/disabled state."""
|
||||
try:
|
||||
btn = self.query_one("#action-button", Button)
|
||||
btn.label = "↵ Send"
|
||||
btn.disabled = True
|
||||
btn.remove_class("pause-mode")
|
||||
btn.add_class("send-mode")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def on_chat_text_area_submitted(self, message: ChatTextArea.Submitted) -> None:
|
||||
"""Handle chat input submission."""
|
||||
await self._submit_input(message.text)
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Toggle the Send button based on whether there is text in the input."""
|
||||
if event.text_area.id != "chat-input":
|
||||
return
|
||||
# Only update button if we're not currently executing (Pause takes priority)
|
||||
if self._current_exec_id is not None:
|
||||
return
|
||||
has_text = bool(event.text_area.text.strip())
|
||||
if has_text:
|
||||
self._set_button_send_mode()
|
||||
else:
|
||||
self._set_button_idle_mode()
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle action button click — Send when idle, Pause when executing."""
|
||||
if event.button.id != "action-button":
|
||||
return
|
||||
if self._current_exec_id is not None:
|
||||
# Execution running → act as Pause
|
||||
await self._cmd_pause()
|
||||
else:
|
||||
# No execution → act as Send (submit whatever is in the input)
|
||||
chat_input = self.query_one("#chat-input", ChatTextArea)
|
||||
text = chat_input.text.strip()
|
||||
if text:
|
||||
chat_input.clear()
|
||||
await self._submit_input(text)
|
||||
|
||||
async def _submit_input(self, user_input: str) -> None:
|
||||
"""Handle submitted text — either start new execution or inject input."""
|
||||
if not user_input:
|
||||
@@ -1176,6 +1286,9 @@ class ChatRepl(Vertical):
|
||||
indicator.update("Thinking...")
|
||||
indicator.display = True
|
||||
|
||||
# Switch button to Pause mode
|
||||
self._set_button_pause_mode()
|
||||
|
||||
# Keep input enabled for commands during execution
|
||||
chat_input = self.query_one("#chat-input", ChatTextArea)
|
||||
chat_input.placeholder = "Commands available: /pause, /sessions, /help"
|
||||
@@ -1325,6 +1438,9 @@ class ChatRepl(Vertical):
|
||||
self._pending_ask_question = ""
|
||||
self._log_buffer.clear()
|
||||
|
||||
# Reset button to idle/send mode
|
||||
self._set_button_idle_mode()
|
||||
|
||||
# Re-enable input
|
||||
chat_input = self.query_one("#chat-input", ChatTextArea)
|
||||
chat_input.disabled = False
|
||||
@@ -1348,6 +1464,9 @@ class ChatRepl(Vertical):
|
||||
self._active_node_id = None
|
||||
self._log_buffer.clear()
|
||||
|
||||
# Reset button to idle/send mode
|
||||
self._set_button_idle_mode()
|
||||
|
||||
# Re-enable input
|
||||
chat_input = self.query_one("#chat-input", ChatTextArea)
|
||||
chat_input.disabled = False
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for ChatTextArea key handling (Enter submits, Shift+Enter / Ctrl+J insert newlines)."""
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
from framework.tui.widgets.chat_repl import ChatTextArea
|
||||
|
||||
|
||||
class ChatTextAreaApp(App):
|
||||
"""Minimal app that mounts a ChatTextArea for testing."""
|
||||
|
||||
submitted_texts: list[str]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield ChatTextArea(id="input")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.submitted_texts = []
|
||||
|
||||
def on_chat_text_area_submitted(self, message: ChatTextArea.Submitted) -> None:
|
||||
self.submitted_texts.append(message.text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return ChatTextAreaApp()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_submits_text(app):
|
||||
"""Pressing Enter should post a Submitted message and clear the widget."""
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("h", "e", "l", "l", "o")
|
||||
await pilot.press("enter")
|
||||
|
||||
assert app.submitted_texts == ["hello"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_on_empty_does_not_submit(app):
|
||||
"""Pressing Enter with no text should not post a Submitted message."""
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
|
||||
assert app.submitted_texts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_enter_inserts_newline(app):
|
||||
"""Shift+Enter should insert a newline, not submit."""
|
||||
async with app.run_test() as pilot:
|
||||
widget = app.query_one("#input", ChatTextArea)
|
||||
|
||||
await pilot.press("a")
|
||||
await pilot.press("shift+enter")
|
||||
await pilot.press("b")
|
||||
|
||||
assert app.submitted_texts == []
|
||||
assert "\n" in widget.text
|
||||
assert widget.text.startswith("a")
|
||||
assert widget.text.endswith("b")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_j_inserts_newline(app):
|
||||
"""Ctrl+J should insert a newline (fallback for terminals without Shift+Enter)."""
|
||||
async with app.run_test() as pilot:
|
||||
widget = app.query_one("#input", ChatTextArea)
|
||||
|
||||
await pilot.press("a")
|
||||
await pilot.press("ctrl+j")
|
||||
await pilot.press("b")
|
||||
|
||||
assert app.submitted_texts == []
|
||||
assert "\n" in widget.text
|
||||
assert widget.text.startswith("a")
|
||||
assert widget.text.endswith("b")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiline_submit(app):
|
||||
"""Typing multiline text via Ctrl+J then pressing Enter should submit all lines."""
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("a")
|
||||
await pilot.press("ctrl+j")
|
||||
await pilot.press("b")
|
||||
await pilot.press("enter")
|
||||
|
||||
assert len(app.submitted_texts) == 1
|
||||
assert app.submitted_texts[0] == "a\nb"
|
||||
@@ -826,3 +826,52 @@ class TestAsyncComplete:
|
||||
assert call_thread_ids[0] != main_thread_id, (
|
||||
"Base acomplete() should offload sync complete() to a thread pool"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentRunner._is_local_model — parameterized tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsLocalModel:
|
||||
"""Parameterized tests for AgentRunner._is_local_model()."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"ollama/llama3",
|
||||
"ollama/mistral",
|
||||
"ollama_chat/llama3",
|
||||
"vllm/mistral",
|
||||
"lm_studio/phi3",
|
||||
"llamacpp/llama-7b",
|
||||
"Ollama/Llama3", # case-insensitive
|
||||
"VLLM/Mistral",
|
||||
],
|
||||
)
|
||||
def test_local_models_return_true(self, model):
|
||||
"""Local model prefixes should be recognized."""
|
||||
from framework.runner.runner import AgentRunner
|
||||
|
||||
assert AgentRunner._is_local_model(model) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"anthropic/claude-3-haiku",
|
||||
"openai/gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"claude-3-haiku-20240307",
|
||||
"gemini/gemini-1.5-flash",
|
||||
"groq/llama3-70b",
|
||||
"mistral/mistral-large",
|
||||
"azure/gpt-4",
|
||||
"cohere/command-r",
|
||||
"together/llama3-70b",
|
||||
],
|
||||
)
|
||||
def test_cloud_models_return_false(self, model):
|
||||
"""Cloud model prefixes should not be treated as local."""
|
||||
from framework.runner.runner import AgentRunner
|
||||
|
||||
assert AgentRunner._is_local_model(model) is False
|
||||
|
||||
+166
-14
@@ -1,5 +1,150 @@
|
||||
# Hive Agent Framework: Triangulated Verification for Reliable Goal-Driven Agents
|
||||
|
||||
## System Architecture Overview
|
||||
|
||||
The Hive framework is organized around five core subsystems that collaborate to execute goal-driven agents reliably. The following diagram shows how these subsystems connect:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
%% Main Entity
|
||||
User([User])
|
||||
|
||||
%% =========================================
|
||||
%% EXTERNAL EVENT SOURCES
|
||||
%% =========================================
|
||||
subgraph ExtEventSource [External Event Source]
|
||||
E_Sch["Schedulers"]
|
||||
E_WH["Webhook"]
|
||||
E_SSE["SSE"]
|
||||
end
|
||||
|
||||
%% =========================================
|
||||
%% SYSTEM NODES
|
||||
%% =========================================
|
||||
subgraph WorkerBees [Worker Bees]
|
||||
WB_C["Conversation"]
|
||||
WB_SP["System prompt"]
|
||||
|
||||
subgraph Graph [Graph]
|
||||
direction TB
|
||||
N1["Node"] --> N2["Node"] --> N3["Node"]
|
||||
N1 -.-> AN["Active Node"]
|
||||
N2 -.-> AN
|
||||
N3 -.-> AN
|
||||
|
||||
%% Nested Event Loop Node
|
||||
subgraph EventLoopNode [Event Loop Node]
|
||||
ELN_L["listener"]
|
||||
ELN_SP["System Prompt<br/>(Task)"]
|
||||
ELN_EL["Event loop"]
|
||||
ELN_C["Conversation"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
subgraph JudgeNode [Judge]
|
||||
J_C["Criteria"]
|
||||
J_P["Principles"]
|
||||
J_EL["Event loop"] <--> J_S["Scheduler"]
|
||||
end
|
||||
|
||||
subgraph QueenBee [Queen Bee]
|
||||
QB_SP["System prompt"]
|
||||
QB_EL["Event loop"]
|
||||
QB_C["Conversation"]
|
||||
end
|
||||
|
||||
subgraph Infra [Infra]
|
||||
SA["Sub Agent"]
|
||||
TR["Tool Registry"]
|
||||
WTM["Write through Conversation Memory<br/>(Logs/RAM/Harddrive)"]
|
||||
SM["Shared Memory<br/>(State/Harddrive)"]
|
||||
EB["Event Bus<br/>(RAM)"]
|
||||
CS["Credential Store<br/>(Harddrive/Cloud)"]
|
||||
end
|
||||
|
||||
subgraph PC [PC]
|
||||
B["Browser"]
|
||||
CB["Codebase<br/>v 0.0.x ... v n.n.n"]
|
||||
end
|
||||
|
||||
%% =========================================
|
||||
%% CONNECTIONS & DATA FLOW
|
||||
%% =========================================
|
||||
|
||||
%% External Event Routing
|
||||
E_Sch --> ELN_L
|
||||
E_WH --> ELN_L
|
||||
E_SSE --> ELN_L
|
||||
ELN_L -->|"triggers"| ELN_EL
|
||||
|
||||
%% User Interactions
|
||||
User -->|"Talk"| WB_C
|
||||
User -->|"Talk"| QB_C
|
||||
User -->|"Read/Write Access"| CS
|
||||
|
||||
%% Inter-System Logic
|
||||
ELN_C <-->|"Mirror"| WB_C
|
||||
WB_C -->|"Focus"| AN
|
||||
|
||||
WorkerBees -->|"Inquire"| JudgeNode
|
||||
JudgeNode -->|"Approve"| WorkerBees
|
||||
|
||||
%% Judge Alignments
|
||||
J_C <-.->|"aligns"| WB_SP
|
||||
J_P <-.->|"aligns"| QB_SP
|
||||
|
||||
%% Escalate path
|
||||
J_EL -->|"Report (Escalate)"| QB_EL
|
||||
|
||||
%% Pub/Sub Logic
|
||||
AN -->|"publish"| EB
|
||||
EB -->|"subscribe"| QB_C
|
||||
|
||||
%% Infra and Process Spawning
|
||||
ELN_EL -->|"Spawn"| SA
|
||||
SA -->|"Inform"| ELN_EL
|
||||
SA -->|"Starts"| B
|
||||
B -->|"Report"| ELN_EL
|
||||
TR -->|"Assigned"| EventLoopNode
|
||||
CB -->|"Modify Worker Bee"| WorkerBees
|
||||
|
||||
%% =========================================
|
||||
%% SHARED MEMORY & LOGS ACCESS
|
||||
%% =========================================
|
||||
|
||||
%% Worker Bees Access
|
||||
Graph <-->|"Read/Write"| WTM
|
||||
Graph <-->|"Read/Write"| SM
|
||||
|
||||
%% Queen Bee Access
|
||||
QB_C <-->|"Read/Write"| WTM
|
||||
QB_EL <-->|"Read/Write"| SM
|
||||
|
||||
%% Credentials Access
|
||||
CS -->|"Read Access"| QB_C
|
||||
```
|
||||
|
||||
### Key Subsystems
|
||||
|
||||
| Subsystem | Role | Description |
|
||||
| ------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Event Loop Node** | Entry point | Listens for external events (schedulers, webhooks, SSE), triggers the event loop, and spawns sub-agents. Its conversation mirrors the Worker Bees conversation for context continuity. |
|
||||
| **Worker Bees** | Execution | A graph of nodes that execute the actual work. Each node in the graph can become the Active Node. Workers maintain their own conversation and system prompt, and read/write to shared memory. |
|
||||
| **Judge** | Evaluation | Evaluates Worker Bee output against criteria (aligned with Worker system prompt) and principles (aligned with Queen Bee system prompt). Runs on a scheduled event loop and escalates to the Queen Bee when needed. |
|
||||
| **Queen Bee** | Oversight | The orchestration layer. Subscribes to Active Node events via the Event Bus, receives escalation reports from the Judge, and has read/write access to shared memory and credentials. Users can talk directly to the Queen Bee. |
|
||||
| **Infra** | Services | Shared infrastructure: Tool Registry (assigned to Event Loop Nodes), Write-through Conversation Memory (logs across RAM and disk), Shared Memory (state on disk), Event Bus (pub/sub in RAM), Credential Store (encrypted on disk or cloud), and Sub Agents. |
|
||||
|
||||
### Data Flow Patterns
|
||||
|
||||
- **External triggers**: Schedulers, Webhooks, and SSE events flow into the Event Loop Node's listener, which triggers the event loop to spawn sub-agents or start browser-based tasks.
|
||||
- **User interaction**: Users talk directly to Worker Bees (for task execution) or the Queen Bee (for oversight). Users also have read/write access to the Credential Store.
|
||||
- **Worker-Judge loop**: Worker Bees inquire with the Judge after completing work. The Judge approves the output or escalates to the Queen Bee.
|
||||
- **Pub/Sub**: The Active Node publishes events to the Event Bus. The Queen Bee subscribes for real-time visibility.
|
||||
- **Adaptiveness**: The Codebase modifies Worker Bees, enabling the framework to evolve agent graphs across versions.
|
||||
|
||||
---
|
||||
|
||||
## The Core Problem: The Ground Truth Crisis in Agentic Systems
|
||||
|
||||
Modern agent frameworks face a fundamental epistemological challenge: **there is no reliable oracle**.
|
||||
@@ -324,30 +469,35 @@ High Confidence ─────────────────────
|
||||
|
||||
## The Complete Picture
|
||||
|
||||
The system architecture (see diagram above) maps onto four logical layers. The **Goal Layer** defines what the Queen Bee and Judge align on. The **Execution Layer** is the Worker Bees graph. The **Verification Layer** is the Judge with its triangulated signals. The **Reflexion Layer** is the feedback loop between Worker Bees and Judge.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ HIVE AGENT FRAMEWORK │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ GOAL LAYER │ │
|
||||
│ │ GOAL LAYER (Queen Bee) │ │
|
||||
│ │ • Success criteria (weighted, multi-metric) │ │
|
||||
│ │ • Constraints (hard/soft boundaries) │ │
|
||||
│ │ • Principles aligned with Queen Bee system prompt │ │
|
||||
│ │ • Context (domain knowledge, preferences) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ EXECUTION LAYER │ │
|
||||
│ │ EXECUTION LAYER (Worker Bees) │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │ Graph │───►│ Worker │───►│ Shared │ │ │
|
||||
│ │ │ Graph │───►│ Active │───►│ Shared │ │ │
|
||||
│ │ │ Executor │ │ Node │ │ Memory │ │ │
|
||||
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
|
||||
│ │ Event Loop Node triggers │ Sub Agents, Browser tasks │ │
|
||||
│ │ Tool Registry provides tools │ Event Bus publishes events │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ TRIANGULATED VERIFICATION │ │
|
||||
│ │ TRIANGULATED VERIFICATION (Judge) │ │
|
||||
│ │ │ │
|
||||
│ │ Signal 1 Signal 2 Signal 3 │ │
|
||||
│ │ ┌────────┐ ┌──────────┐ ┌─────────┐ │ │
|
||||
@@ -356,9 +506,9 @@ High Confidence ─────────────────────
|
||||
│ │ └────────┘ └──────────┘ └─────────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ └────────────────┴──────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ Confidence from Agreement │ │
|
||||
│ │ Criteria aligned with Worker Bee system prompt │ │
|
||||
│ │ Principles aligned with Queen Bee system prompt │ │
|
||||
│ │ Confidence from agreement across signals │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
@@ -367,7 +517,7 @@ High Confidence ─────────────────────
|
||||
│ │ • ACCEPT: Proceed with confidence │ │
|
||||
│ │ • RETRY: Learn from failure, try again │ │
|
||||
│ │ • REPLAN: Strategy failed, change approach │ │
|
||||
│ │ • ESCALATE: Uncertainty too high, ask human │ │
|
||||
│ │ • ESCALATE: Report to Queen Bee, ask human │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
@@ -628,17 +778,19 @@ class SignalWeights:
|
||||
|
||||
## Summary
|
||||
|
||||
The Hive Agent Framework addresses the fundamental reliability crisis in agentic systems through **Triangulated Verification** and a roadmap toward **Online Learning**:
|
||||
The Hive Agent Framework addresses the fundamental reliability crisis in agentic systems through a layered architecture of **Event Loop Nodes**, **Worker Bees**, **Judges**, and a **Queen Bee**, unified by **Triangulated Verification** and a roadmap toward **Online Learning**:
|
||||
|
||||
1. **The Problem**: No single evaluation signal is trustworthy. Tests can be gamed, model confidence is miscalibrated, LLM judges hallucinate.
|
||||
1. **The Architecture**: External events enter through Event Loop Nodes, which trigger Worker Bees to execute graph-based tasks. A Judge evaluates output using triangulated signals. A Queen Bee provides oversight, receives escalations, and subscribes to events via the Event Bus. Shared infrastructure (memory, credentials, tool registry) connects all subsystems.
|
||||
|
||||
2. **The Solution**: Confidence emerges from agreement across multiple independent signals—deterministic rules, semantic evaluation, and human judgment.
|
||||
2. **The Problem**: No single evaluation signal is trustworthy. Tests can be gamed, model confidence is miscalibrated, LLM judges hallucinate.
|
||||
|
||||
3. **The Foundation**: Goal-driven architecture ensures we're optimizing for user intent, not metric gaming. The reflexion loop enables learning from failure without expensive search.
|
||||
3. **The Solution**: Confidence emerges from agreement across multiple independent signals—deterministic rules, semantic evaluation, and human judgment. The Judge's criteria align with Worker Bee prompts; its principles align with the Queen Bee.
|
||||
|
||||
4. **The Learning Path**: Human escalations aren't just fallbacks—they're training signals. Confidence calibration tunes thresholds automatically. Rule generation transforms repeated human decisions into deterministic automation.
|
||||
4. **The Foundation**: Goal-driven architecture ensures we're optimizing for user intent, not metric gaming. The reflexion loop between Worker Bees and Judge enables learning from failure without expensive search.
|
||||
|
||||
5. **The Result**: Agents that are reliable not because they're always right, but because they **know when they don't know**—and get smarter every time they ask for help.
|
||||
5. **The Learning Path**: Human escalations aren't just fallbacks—they're training signals. Confidence calibration tunes thresholds automatically. Rule generation transforms repeated human decisions into deterministic automation.
|
||||
|
||||
6. **The Result**: Agents that are reliable not because they're always right, but because they **know when they don't know**—and get smarter every time they ask for help.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+848
-263
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
# Competitive Intelligence Agent (Community)
|
||||
## Built by https://github.com/nafiyad
|
||||
|
||||
An autonomous agent that monitors competitor websites, news sources, and GitHub repositories to deliver structured digests with key insights and trend analysis.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.11+** with `uv`
|
||||
- **ANTHROPIC_API_KEY** — set in your `.env` or environment
|
||||
- **GITHUB_TOKEN** *(optional)* — for GitHub activity monitoring
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Interactive Shell
|
||||
```bash
|
||||
cd examples/templates
|
||||
uv run python -m competitive_intel_agent shell
|
||||
```
|
||||
|
||||
### CLI Run
|
||||
```bash
|
||||
# With inline JSON
|
||||
uv run python -m competitive_intel_agent run \
|
||||
--competitors '[{"name":"Acme","website":"https://acme.com","github":"acme-org"},{"name":"Beta Inc","website":"https://beta.io","github":null}]' \
|
||||
--focus-areas "pricing,features,partnerships,hiring" \
|
||||
--frequency weekly
|
||||
|
||||
# From a file
|
||||
uv run python -m competitive_intel_agent run --competitors competitors.json
|
||||
```
|
||||
|
||||
### TUI Dashboard
|
||||
```bash
|
||||
uv run python -m competitive_intel_agent tui
|
||||
```
|
||||
|
||||
### Validate & Info
|
||||
```bash
|
||||
uv run python -m competitive_intel_agent validate
|
||||
uv run python -m competitive_intel_agent info
|
||||
```
|
||||
|
||||
## Agent Graph
|
||||
|
||||
```
|
||||
intake → web-scraper → news-search → github-monitor → aggregator → analysis → report
|
||||
↑
|
||||
(skipped if no competitors have GitHub)
|
||||
```
|
||||
|
||||
| Node | Purpose | Tools | Client-Facing |
|
||||
|------|---------|-------|:---:|
|
||||
| **intake** | Collect competitor list & focus areas | — | ✅ |
|
||||
| **web-scraper** | Scrape competitor websites | web_search, web_scrape | |
|
||||
| **news-search** | Search news & press releases | web_search, web_scrape | |
|
||||
| **github-monitor** | Track public GitHub activity | github_* | |
|
||||
| **aggregator** | Merge, deduplicate, persist | save_data, load_data | |
|
||||
| **analysis** | Extract insights & trends | load_data, save_data | |
|
||||
| **report** | Generate HTML digest | save_data, serve_file | ✅ |
|
||||
|
||||
## Input Format
|
||||
|
||||
```json
|
||||
{
|
||||
"competitors": [
|
||||
{"name": "CompetitorA", "website": "https://competitor-a.com", "github": "competitor-a"},
|
||||
{"name": "CompetitorB", "website": "https://competitor-b.com", "github": null}
|
||||
],
|
||||
"focus_areas": ["pricing", "new_features", "hiring", "partnerships"],
|
||||
"report_frequency": "weekly"
|
||||
}
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
The agent produces an HTML report saved to `~/.hive/agents/competitive_intel_agent/` with:
|
||||
- 🔥 **Key Highlights** — most significant competitive moves
|
||||
- 📊 **Per-Competitor Tables** — category, update, source, date
|
||||
- 📈 **30-Day Trends** — patterns across competitors over time
|
||||
|
||||
Historical snapshots are stored for trend comparison on subsequent runs.
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Competitive Intelligence Agent — Automated competitor monitoring and reporting.
|
||||
|
||||
Monitors competitor websites, news sources, and GitHub repositories to deliver
|
||||
structured weekly digests with key insights and 30-day trend analysis for
|
||||
product and marketing teams.
|
||||
"""
|
||||
|
||||
from .agent import CompetitiveIntelAgent, default_agent, goal, nodes, edges
|
||||
from .config import RuntimeConfig, AgentMetadata, default_config, metadata
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
__all__ = [
|
||||
"CompetitiveIntelAgent",
|
||||
"default_agent",
|
||||
"goal",
|
||||
"nodes",
|
||||
"edges",
|
||||
"RuntimeConfig",
|
||||
"AgentMetadata",
|
||||
"default_config",
|
||||
"metadata",
|
||||
]
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
CLI entry point for Competitive Intelligence Agent.
|
||||
|
||||
Uses AgentRuntime for multi-entrypoint support with HITL pause/resume.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from .agent import CompetitiveIntelAgent, default_agent
|
||||
|
||||
|
||||
def setup_logging(verbose: bool = False, debug: bool = False) -> None:
|
||||
"""Configure logging for execution visibility."""
|
||||
if debug:
|
||||
level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s"
|
||||
elif verbose:
|
||||
level, fmt = logging.INFO, "%(message)s"
|
||||
else:
|
||||
level, fmt = logging.WARNING, "%(levelname)s: %(message)s"
|
||||
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
|
||||
logging.getLogger("framework").setLevel(level)
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="1.0.0")
|
||||
def cli() -> None:
|
||||
"""Competitive Intelligence Agent - Monitor competitors and deliver weekly digests."""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--competitors",
|
||||
"-c",
|
||||
type=str,
|
||||
required=True,
|
||||
help='Competitors JSON string or file path (e.g. \'[{"name":"Acme","website":"https://acme.com"}]\')',
|
||||
)
|
||||
@click.option(
|
||||
"--focus-areas",
|
||||
"-f",
|
||||
type=str,
|
||||
default="pricing,features,partnerships,hiring",
|
||||
help="Comma-separated focus areas (default: pricing,features,partnerships,hiring)",
|
||||
)
|
||||
@click.option(
|
||||
"--frequency",
|
||||
type=click.Choice(["weekly", "daily", "monthly"]),
|
||||
default="weekly",
|
||||
help="Report frequency (default: weekly)",
|
||||
)
|
||||
@click.option("--quiet", "-q", is_flag=True, help="Only output result JSON")
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Show execution details")
|
||||
@click.option("--debug", is_flag=True, help="Show debug logging")
|
||||
def run(
|
||||
competitors: str,
|
||||
focus_areas: str,
|
||||
frequency: str,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
debug: bool,
|
||||
) -> None:
|
||||
"""Execute competitive intelligence gathering and report generation."""
|
||||
if not quiet:
|
||||
setup_logging(verbose=verbose, debug=debug)
|
||||
|
||||
# Parse competitors — accept JSON string or file path
|
||||
try:
|
||||
competitors_data = json.loads(competitors)
|
||||
except json.JSONDecodeError:
|
||||
# Try loading from file
|
||||
try:
|
||||
with open(competitors) as f:
|
||||
competitors_data = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
click.echo(f"Error parsing competitors: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"competitors_input": json.dumps({
|
||||
"competitors": competitors_data,
|
||||
"focus_areas": [a.strip() for a in focus_areas.split(",")],
|
||||
"report_frequency": frequency,
|
||||
})
|
||||
}
|
||||
|
||||
result = asyncio.run(default_agent.run(context))
|
||||
|
||||
output_data: dict[str, Any] = {
|
||||
"success": result.success,
|
||||
"steps_executed": result.steps_executed,
|
||||
"output": result.output,
|
||||
}
|
||||
if result.error:
|
||||
output_data["error"] = result.error
|
||||
|
||||
click.echo(json.dumps(output_data, indent=2, default=str))
|
||||
sys.exit(0 if result.success else 1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Show execution details")
|
||||
@click.option("--debug", is_flag=True, help="Show debug logging")
|
||||
def tui(verbose: bool, debug: bool) -> None:
|
||||
"""Launch the TUI dashboard for interactive competitive intelligence."""
|
||||
setup_logging(verbose=verbose, debug=debug)
|
||||
|
||||
try:
|
||||
from framework.tui.app import AdenTUI
|
||||
except ImportError:
|
||||
click.echo(
|
||||
"TUI requires the 'textual' package. Install with: pip install textual"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import create_agent_runtime
|
||||
from framework.runtime.event_bus import EventBus
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
|
||||
async def run_with_tui() -> None:
|
||||
agent = CompetitiveIntelAgent()
|
||||
|
||||
# Build graph and tools
|
||||
agent._event_bus = EventBus()
|
||||
agent._tool_registry = ToolRegistry()
|
||||
|
||||
storage_path = Path.home() / ".hive" / "agents" / "competitive_intel_agent"
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mcp_config_path = Path(__file__).parent / "mcp_servers.json"
|
||||
if mcp_config_path.exists():
|
||||
agent._tool_registry.load_mcp_config(mcp_config_path)
|
||||
|
||||
llm = LiteLLMProvider(
|
||||
model=agent.config.model,
|
||||
api_key=agent.config.api_key,
|
||||
api_base=agent.config.api_base,
|
||||
)
|
||||
|
||||
tools = list(agent._tool_registry.get_tools().values())
|
||||
tool_executor = agent._tool_registry.get_executor()
|
||||
graph = agent._build_graph()
|
||||
|
||||
runtime = create_agent_runtime(
|
||||
graph=graph,
|
||||
goal=agent.goal,
|
||||
storage_path=storage_path,
|
||||
entry_points=[
|
||||
EntryPointSpec(
|
||||
id="start",
|
||||
name="Start Competitive Analysis",
|
||||
entry_node="intake",
|
||||
trigger_type="manual",
|
||||
isolation_level="isolated",
|
||||
),
|
||||
],
|
||||
llm=llm,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
)
|
||||
|
||||
await runtime.start()
|
||||
|
||||
try:
|
||||
app = AdenTUI(runtime)
|
||||
await app.run_async()
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(run_with_tui())
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--json", "output_json", is_flag=True)
|
||||
def info(output_json: bool) -> None:
|
||||
"""Show agent information."""
|
||||
info_data = default_agent.info()
|
||||
if output_json:
|
||||
click.echo(json.dumps(info_data, indent=2))
|
||||
else:
|
||||
click.echo(f"Agent: {info_data['name']}")
|
||||
click.echo(f"Version: {info_data['version']}")
|
||||
click.echo(f"Description: {info_data['description']}")
|
||||
click.echo(f"\nGoal: {info_data['goal']['name']}")
|
||||
click.echo(f" {info_data['goal']['description']}")
|
||||
click.echo(f"\nNodes: {', '.join(info_data['nodes'])}")
|
||||
# click.echo(f"Client-facing: {', '.join(info_data['client_facing_nodes'])}")
|
||||
click.echo(f"Entry: {info_data['entry_node']}")
|
||||
click.echo(f"Terminal: {', '.join(info_data['terminal_nodes'])}")
|
||||
click.echo(f"Edges: {len(info_data['edges'])}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def validate() -> None:
|
||||
"""Validate agent structure."""
|
||||
validation = default_agent.validate()
|
||||
if validation["valid"]:
|
||||
click.echo("✅ Agent is valid")
|
||||
if validation["warnings"]:
|
||||
for warning in validation["warnings"]:
|
||||
click.echo(f" ⚠️ {warning}")
|
||||
else:
|
||||
click.echo("❌ Agent has errors:")
|
||||
for error in validation["errors"]:
|
||||
click.echo(f" ERROR: {error}")
|
||||
sys.exit(0 if validation["valid"] else 1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--verbose", "-v", is_flag=True)
|
||||
def shell(verbose: bool) -> None:
|
||||
"""Interactive competitive intelligence session (CLI, no TUI)."""
|
||||
asyncio.run(_interactive_shell(verbose))
|
||||
|
||||
|
||||
async def _interactive_shell(verbose: bool = False) -> None:
|
||||
"""Async interactive shell."""
|
||||
setup_logging(verbose=verbose)
|
||||
|
||||
click.echo("=== Competitive Intelligence Agent ===")
|
||||
click.echo("Provide competitor details to begin analysis (or 'quit' to exit):\n")
|
||||
|
||||
agent = CompetitiveIntelAgent()
|
||||
await agent.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
user_input = await asyncio.get_event_loop().run_in_executor(
|
||||
None, input, "Competitors> "
|
||||
)
|
||||
if user_input.lower() in ["quit", "exit", "q"]:
|
||||
click.echo("Goodbye!")
|
||||
break
|
||||
|
||||
if not user_input.strip():
|
||||
continue
|
||||
|
||||
click.echo("\nGathering competitive intelligence...\n")
|
||||
|
||||
result = await agent.trigger_and_wait(
|
||||
"start", {"competitors_input": user_input}
|
||||
)
|
||||
|
||||
if result is None:
|
||||
click.echo("\n[Execution timed out]\n")
|
||||
continue
|
||||
|
||||
if result.success:
|
||||
output = result.output
|
||||
status = output.get("delivery_status", "unknown")
|
||||
click.echo(f"\nAnalysis complete (status: {status})\n")
|
||||
else:
|
||||
click.echo(f"\nAnalysis failed: {result.error}\n")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nGoodbye!")
|
||||
break
|
||||
except Exception as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
await agent.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,403 @@
|
||||
{
|
||||
"agent": {
|
||||
"id": "competitive_intel_agent",
|
||||
"name": "Competitive Intelligence Report",
|
||||
"version": "1.0.0",
|
||||
"description": "Monitor competitor websites, news sources, and GitHub repositories to produce a structured weekly digest with key insights, detailed findings per competitor, and 30-day trend analysis."
|
||||
},
|
||||
"graph": {
|
||||
"id": "competitive_intel_agent-graph",
|
||||
"goal_id": "competitive-intelligence-report",
|
||||
"version": "1.0.0",
|
||||
"entry_node": "intake",
|
||||
"entry_points": {
|
||||
"start": "intake"
|
||||
},
|
||||
"pause_nodes": [],
|
||||
"terminal_nodes": [
|
||||
"report"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "intake",
|
||||
"name": "Competitor Intake",
|
||||
"description": "Collect competitor list, focus areas, and report preferences from the user",
|
||||
"node_type": "event_loop",
|
||||
"input_keys": [
|
||||
"competitors_input"
|
||||
],
|
||||
"output_keys": [
|
||||
"competitors",
|
||||
"focus_areas",
|
||||
"report_frequency",
|
||||
"has_github_competitors"
|
||||
],
|
||||
"nullable_output_keys": [],
|
||||
"input_schema": {},
|
||||
"output_schema": {},
|
||||
"system_prompt": null,
|
||||
"tools": [],
|
||||
"model": null,
|
||||
"function": null,
|
||||
"routes": {},
|
||||
"max_retries": 3,
|
||||
"retry_on": [],
|
||||
"max_node_visits": 1,
|
||||
"output_model": null,
|
||||
"max_validation_retries": 2,
|
||||
"client_facing": true
|
||||
},
|
||||
{
|
||||
"id": "web-scraper",
|
||||
"name": "Website Monitor",
|
||||
"description": "Scrape competitor websites for pricing, features, and announcements",
|
||||
"node_type": "event_loop",
|
||||
"input_keys": [
|
||||
"competitors",
|
||||
"focus_areas"
|
||||
],
|
||||
"output_keys": [
|
||||
"web_findings"
|
||||
],
|
||||
"nullable_output_keys": [],
|
||||
"input_schema": {},
|
||||
"output_schema": {},
|
||||
"system_prompt": null,
|
||||
"tools": [
|
||||
"web_search",
|
||||
"web_scrape"
|
||||
],
|
||||
"model": null,
|
||||
"function": null,
|
||||
"routes": {},
|
||||
"max_retries": 3,
|
||||
"retry_on": [],
|
||||
"max_node_visits": 1,
|
||||
"output_model": null,
|
||||
"max_validation_retries": 2,
|
||||
"client_facing": false
|
||||
},
|
||||
{
|
||||
"id": "news-search",
|
||||
"name": "News & Press Monitor",
|
||||
"description": "Search for competitor mentions in news, press releases, and industry publications",
|
||||
"node_type": "event_loop",
|
||||
"input_keys": [
|
||||
"competitors",
|
||||
"focus_areas"
|
||||
],
|
||||
"output_keys": [
|
||||
"news_findings"
|
||||
],
|
||||
"nullable_output_keys": [],
|
||||
"input_schema": {},
|
||||
"output_schema": {},
|
||||
"system_prompt": null,
|
||||
"tools": [
|
||||
"web_search",
|
||||
"web_scrape"
|
||||
],
|
||||
"model": null,
|
||||
"function": null,
|
||||
"routes": {},
|
||||
"max_retries": 3,
|
||||
"retry_on": [],
|
||||
"max_node_visits": 1,
|
||||
"output_model": null,
|
||||
"max_validation_retries": 2,
|
||||
"client_facing": false
|
||||
},
|
||||
{
|
||||
"id": "github-monitor",
|
||||
"name": "GitHub Activity Monitor",
|
||||
"description": "Track public GitHub repository activity for competitors with GitHub presence",
|
||||
"node_type": "event_loop",
|
||||
"input_keys": [
|
||||
"competitors"
|
||||
],
|
||||
"output_keys": [
|
||||
"github_findings"
|
||||
],
|
||||
"nullable_output_keys": [],
|
||||
"input_schema": {},
|
||||
"output_schema": {},
|
||||
"system_prompt": null,
|
||||
"tools": [
|
||||
"github_list_repos",
|
||||
"github_get_repo",
|
||||
"github_search_repos"
|
||||
],
|
||||
"model": null,
|
||||
"function": null,
|
||||
"routes": {},
|
||||
"max_retries": 3,
|
||||
"retry_on": [],
|
||||
"max_node_visits": 1,
|
||||
"output_model": null,
|
||||
"max_validation_retries": 2,
|
||||
"client_facing": false
|
||||
},
|
||||
{
|
||||
"id": "aggregator",
|
||||
"name": "Data Aggregator",
|
||||
"description": "Combine findings from all sources, deduplicate, and structure for analysis",
|
||||
"node_type": "event_loop",
|
||||
"input_keys": [
|
||||
"competitors",
|
||||
"web_findings",
|
||||
"news_findings",
|
||||
"github_findings"
|
||||
],
|
||||
"output_keys": [
|
||||
"aggregated_findings",
|
||||
"github_findings"
|
||||
],
|
||||
"nullable_output_keys": [
|
||||
"github_findings"
|
||||
],
|
||||
"input_schema": {},
|
||||
"output_schema": {},
|
||||
"system_prompt": null,
|
||||
"tools": [
|
||||
"save_data",
|
||||
"load_data",
|
||||
"list_data_files"
|
||||
],
|
||||
"model": null,
|
||||
"function": null,
|
||||
"routes": {},
|
||||
"max_retries": 3,
|
||||
"retry_on": [],
|
||||
"max_node_visits": 1,
|
||||
"output_model": null,
|
||||
"max_validation_retries": 2,
|
||||
"client_facing": false
|
||||
},
|
||||
{
|
||||
"id": "analysis",
|
||||
"name": "Insight Analysis",
|
||||
"description": "Extract key insights, detect trends, and compare with historical data",
|
||||
"node_type": "event_loop",
|
||||
"input_keys": [
|
||||
"aggregated_findings",
|
||||
"competitors",
|
||||
"focus_areas"
|
||||
],
|
||||
"output_keys": [
|
||||
"key_highlights",
|
||||
"trend_analysis",
|
||||
"detailed_findings"
|
||||
],
|
||||
"nullable_output_keys": [],
|
||||
"input_schema": {},
|
||||
"output_schema": {},
|
||||
"system_prompt": null,
|
||||
"tools": [
|
||||
"load_data",
|
||||
"save_data",
|
||||
"list_data_files"
|
||||
],
|
||||
"model": null,
|
||||
"function": null,
|
||||
"routes": {},
|
||||
"max_retries": 3,
|
||||
"retry_on": [],
|
||||
"max_node_visits": 1,
|
||||
"output_model": null,
|
||||
"max_validation_retries": 2,
|
||||
"client_facing": false
|
||||
},
|
||||
{
|
||||
"id": "report",
|
||||
"name": "Report Generator",
|
||||
"description": "Generate and deliver the competitive intelligence digest as an HTML report",
|
||||
"node_type": "event_loop",
|
||||
"input_keys": [
|
||||
"key_highlights",
|
||||
"trend_analysis",
|
||||
"detailed_findings",
|
||||
"competitors"
|
||||
],
|
||||
"output_keys": [
|
||||
"delivery_status"
|
||||
],
|
||||
"nullable_output_keys": [],
|
||||
"input_schema": {},
|
||||
"output_schema": {},
|
||||
"system_prompt": null,
|
||||
"tools": [
|
||||
"save_data",
|
||||
"load_data",
|
||||
"serve_file_to_user",
|
||||
"list_data_files"
|
||||
],
|
||||
"model": null,
|
||||
"function": null,
|
||||
"routes": {},
|
||||
"max_retries": 3,
|
||||
"retry_on": [],
|
||||
"max_node_visits": 1,
|
||||
"output_model": null,
|
||||
"max_validation_retries": 2,
|
||||
"client_facing": true
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "intake-to-web-scraper",
|
||||
"source": "intake",
|
||||
"target": "web-scraper",
|
||||
"condition": "on_success",
|
||||
"condition_expr": null,
|
||||
"priority": 1,
|
||||
"input_mapping": {}
|
||||
},
|
||||
{
|
||||
"id": "web-scraper-to-news-search",
|
||||
"source": "web-scraper",
|
||||
"target": "news-search",
|
||||
"condition": "on_success",
|
||||
"condition_expr": null,
|
||||
"priority": 1,
|
||||
"input_mapping": {}
|
||||
},
|
||||
{
|
||||
"id": "news-search-to-github-monitor",
|
||||
"source": "news-search",
|
||||
"target": "github-monitor",
|
||||
"condition": "conditional",
|
||||
"condition_expr": "str(has_github_competitors).lower() == 'true'",
|
||||
"priority": 2,
|
||||
"input_mapping": {}
|
||||
},
|
||||
{
|
||||
"id": "news-search-to-aggregator-skip-github",
|
||||
"source": "news-search",
|
||||
"target": "aggregator",
|
||||
"condition": "conditional",
|
||||
"condition_expr": "str(has_github_competitors).lower() != 'true'",
|
||||
"priority": 1,
|
||||
"input_mapping": {}
|
||||
},
|
||||
{
|
||||
"id": "github-monitor-to-aggregator",
|
||||
"source": "github-monitor",
|
||||
"target": "aggregator",
|
||||
"condition": "on_success",
|
||||
"condition_expr": null,
|
||||
"priority": 1,
|
||||
"input_mapping": {}
|
||||
},
|
||||
{
|
||||
"id": "aggregator-to-analysis",
|
||||
"source": "aggregator",
|
||||
"target": "analysis",
|
||||
"condition": "on_success",
|
||||
"condition_expr": null,
|
||||
"priority": 1,
|
||||
"input_mapping": {}
|
||||
},
|
||||
{
|
||||
"id": "analysis-to-report",
|
||||
"source": "analysis",
|
||||
"target": "report",
|
||||
"condition": "on_success",
|
||||
"condition_expr": null,
|
||||
"priority": 1,
|
||||
"input_mapping": {}
|
||||
}
|
||||
],
|
||||
"max_steps": 100,
|
||||
"max_retries_per_node": 3,
|
||||
"description": "Monitor competitor websites, news sources, and GitHub repositories to produce a structured weekly digest with key insights, detailed findings per competitor, and 30-day trend analysis.",
|
||||
"created_at": "2026-02-22T21:09:31.647779"
|
||||
},
|
||||
"goal": {
|
||||
"id": "competitive-intelligence-report",
|
||||
"name": "Competitive Intelligence Report",
|
||||
"description": "Monitor competitor websites, news sources, and GitHub repositories to produce a structured weekly digest with key insights, detailed findings per competitor, and 30-day trend analysis.",
|
||||
"status": "draft",
|
||||
"success_criteria": [
|
||||
{
|
||||
"id": "sc-source-coverage",
|
||||
"description": "Check multiple source types per competitor",
|
||||
"metric": "sources_per_competitor",
|
||||
"target": ">=3",
|
||||
"weight": 0.25,
|
||||
"met": false
|
||||
},
|
||||
{
|
||||
"id": "sc-findings-structured",
|
||||
"description": "All findings structured with competitor, category, update, source, and date",
|
||||
"metric": "findings_structured",
|
||||
"target": "true",
|
||||
"weight": 0.25,
|
||||
"met": false
|
||||
},
|
||||
{
|
||||
"id": "sc-historical-comparison",
|
||||
"description": "Uses stored data to compare with previous reports for trend analysis",
|
||||
"metric": "historical_comparison",
|
||||
"target": "true",
|
||||
"weight": 0.25,
|
||||
"met": false
|
||||
},
|
||||
{
|
||||
"id": "sc-report-delivered",
|
||||
"description": "User receives a formatted, readable competitive intelligence digest",
|
||||
"metric": "report_delivered",
|
||||
"target": "true",
|
||||
"weight": 0.25,
|
||||
"met": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "c-no-fabrication",
|
||||
"description": "Never fabricate findings, news, or data",
|
||||
"constraint_type": "hard",
|
||||
"category": "quality",
|
||||
"check": ""
|
||||
},
|
||||
{
|
||||
"id": "c-source-attribution",
|
||||
"description": "Every finding must include a source URL",
|
||||
"constraint_type": "hard",
|
||||
"category": "quality",
|
||||
"check": ""
|
||||
},
|
||||
{
|
||||
"id": "c-recency",
|
||||
"description": "Prioritize findings from the past 7 days; include up to 30 days",
|
||||
"constraint_type": "soft",
|
||||
"category": "quality",
|
||||
"check": ""
|
||||
}
|
||||
],
|
||||
"context": {},
|
||||
"required_capabilities": [],
|
||||
"input_schema": {},
|
||||
"output_schema": {},
|
||||
"version": "1.0.0",
|
||||
"parent_version": null,
|
||||
"evolution_reason": null,
|
||||
"created_at": "2026-02-22 21:09:31.601236",
|
||||
"updated_at": "2026-02-22 21:09:31.601240"
|
||||
},
|
||||
"required_tools": [
|
||||
"github_get_repo",
|
||||
"github_search_repos",
|
||||
"list_data_files",
|
||||
"github_list_repos",
|
||||
"serve_file_to_user",
|
||||
"save_data",
|
||||
"web_search",
|
||||
"load_data",
|
||||
"web_scrape"
|
||||
],
|
||||
"metadata": {
|
||||
"created_at": "2026-02-22T21:09:31.647803",
|
||||
"node_count": 7,
|
||||
"edge_count": 7
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Agent graph construction for Competitive Intelligence Agent."""
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint, NodeSpec
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.executor import ExecutionResult, GraphExecutor
|
||||
from framework.runtime.event_bus import EventBus
|
||||
from framework.runtime.core import Runtime
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
|
||||
from .config import default_config, metadata, RuntimeConfig
|
||||
from .nodes import (
|
||||
intake_node,
|
||||
web_scraper_node,
|
||||
news_search_node,
|
||||
github_monitor_node,
|
||||
aggregator_node,
|
||||
analysis_node,
|
||||
report_node,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.config import RuntimeConfig
|
||||
|
||||
# Goal definition
|
||||
goal: Goal = Goal(
|
||||
id="competitive-intelligence-report",
|
||||
name="Competitive Intelligence Report",
|
||||
description=(
|
||||
"Monitor competitor websites, news sources, and GitHub repositories "
|
||||
"to produce a structured weekly digest with key insights, detailed "
|
||||
"findings per competitor, and 30-day trend analysis."
|
||||
),
|
||||
success_criteria=[
|
||||
SuccessCriterion(
|
||||
id="sc-source-coverage",
|
||||
description="Check multiple source types per competitor (website, news, GitHub)",
|
||||
metric="sources_per_competitor",
|
||||
target=">=3",
|
||||
weight=0.25,
|
||||
),
|
||||
SuccessCriterion(
|
||||
id="sc-findings-structured",
|
||||
description="All findings structured with competitor, category, update, source, and date",
|
||||
metric="findings_structured",
|
||||
target="true",
|
||||
weight=0.25,
|
||||
),
|
||||
SuccessCriterion(
|
||||
id="sc-historical-comparison",
|
||||
description="Uses stored data to compare with previous reports for trend analysis",
|
||||
metric="historical_comparison",
|
||||
target="true",
|
||||
weight=0.25,
|
||||
),
|
||||
SuccessCriterion(
|
||||
id="sc-report-delivered",
|
||||
description="User receives a formatted, readable competitive intelligence digest",
|
||||
metric="report_delivered",
|
||||
target="true",
|
||||
weight=0.25,
|
||||
),
|
||||
],
|
||||
constraints=[
|
||||
Constraint(
|
||||
id="c-no-fabrication",
|
||||
description="Never fabricate findings, news, or data — only report what was found",
|
||||
constraint_type="hard",
|
||||
category="quality",
|
||||
),
|
||||
Constraint(
|
||||
id="c-source-attribution",
|
||||
description="Every finding must include a source URL",
|
||||
constraint_type="hard",
|
||||
category="quality",
|
||||
),
|
||||
Constraint(
|
||||
id="c-recency",
|
||||
description="Prioritize findings from the past 7 days; include up to 30 days",
|
||||
constraint_type="soft",
|
||||
category="quality",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Node list
|
||||
nodes: list[NodeSpec] = [
|
||||
intake_node,
|
||||
web_scraper_node,
|
||||
news_search_node,
|
||||
github_monitor_node,
|
||||
aggregator_node,
|
||||
analysis_node,
|
||||
report_node,
|
||||
]
|
||||
|
||||
# Edge definitions
|
||||
edges: list[EdgeSpec] = [
|
||||
EdgeSpec(
|
||||
id="intake-to-web-scraper",
|
||||
source="intake",
|
||||
target="web-scraper",
|
||||
condition=EdgeCondition.ON_SUCCESS,
|
||||
priority=1,
|
||||
),
|
||||
EdgeSpec(
|
||||
id="web-scraper-to-news-search",
|
||||
source="web-scraper",
|
||||
target="news-search",
|
||||
condition=EdgeCondition.ON_SUCCESS,
|
||||
priority=1,
|
||||
),
|
||||
EdgeSpec(
|
||||
id="news-search-to-github-monitor",
|
||||
source="news-search",
|
||||
target="github-monitor",
|
||||
condition=EdgeCondition.CONDITIONAL,
|
||||
condition_expr="str(has_github_competitors).lower() == 'true'",
|
||||
priority=2,
|
||||
),
|
||||
EdgeSpec(
|
||||
id="news-search-to-aggregator-skip-github",
|
||||
source="news-search",
|
||||
target="aggregator",
|
||||
condition=EdgeCondition.CONDITIONAL,
|
||||
condition_expr="str(has_github_competitors).lower() != 'true'",
|
||||
priority=1,
|
||||
),
|
||||
EdgeSpec(
|
||||
id="github-monitor-to-aggregator",
|
||||
source="github-monitor",
|
||||
target="aggregator",
|
||||
condition=EdgeCondition.ON_SUCCESS,
|
||||
priority=1,
|
||||
),
|
||||
EdgeSpec(
|
||||
id="aggregator-to-analysis",
|
||||
source="aggregator",
|
||||
target="analysis",
|
||||
condition=EdgeCondition.ON_SUCCESS,
|
||||
priority=1,
|
||||
),
|
||||
EdgeSpec(
|
||||
id="analysis-to-report",
|
||||
source="analysis",
|
||||
target="report",
|
||||
condition=EdgeCondition.ON_SUCCESS,
|
||||
priority=1,
|
||||
),
|
||||
]
|
||||
|
||||
# Graph configuration
|
||||
entry_node: str = "intake"
|
||||
entry_points: dict[str, str] = {"start": "intake"}
|
||||
pause_nodes: list[str] = []
|
||||
terminal_nodes: list[str] = ["report"]
|
||||
|
||||
|
||||
class CompetitiveIntelAgent:
|
||||
"""
|
||||
Competitive Intelligence Agent — 7-node pipeline.
|
||||
|
||||
Flow: intake -> web-scraper -> news-search -> github-monitor -> aggregator -> analysis -> report
|
||||
|
|
||||
(skipped if no GitHub competitors)
|
||||
"""
|
||||
|
||||
def __init__(self, config: RuntimeConfig | None = None) -> None:
|
||||
"""
|
||||
Initialize the Competitive Intelligence Agent.
|
||||
|
||||
Args:
|
||||
config: Optional runtime configuration. Defaults to default_config.
|
||||
"""
|
||||
self.config = config or default_config
|
||||
self.goal = goal
|
||||
self.nodes = nodes
|
||||
self.edges = edges
|
||||
self.entry_node = entry_node
|
||||
self.entry_points = entry_points
|
||||
self.pause_nodes = pause_nodes
|
||||
self.terminal_nodes = terminal_nodes
|
||||
self._executor: GraphExecutor | None = None
|
||||
self._graph: GraphSpec | None = None
|
||||
self._event_bus: EventBus | None = None
|
||||
self._tool_registry: ToolRegistry | None = None
|
||||
|
||||
def _build_graph(self) -> GraphSpec:
|
||||
"""
|
||||
Build the GraphSpec for the competitive intelligence workflow.
|
||||
|
||||
Returns:
|
||||
A GraphSpec defining the agent's logic.
|
||||
"""
|
||||
return GraphSpec(
|
||||
id="competitive-intel-agent-graph",
|
||||
goal_id=self.goal.id,
|
||||
version="1.0.0",
|
||||
entry_node=self.entry_node,
|
||||
entry_points=self.entry_points,
|
||||
terminal_nodes=self.terminal_nodes,
|
||||
pause_nodes=self.pause_nodes,
|
||||
nodes=self.nodes,
|
||||
edges=self.edges,
|
||||
default_model=self.config.model,
|
||||
max_tokens=self.config.max_tokens,
|
||||
loop_config={
|
||||
"max_iterations": 100,
|
||||
"max_tool_calls_per_turn": 20,
|
||||
"max_history_tokens": 32000,
|
||||
},
|
||||
)
|
||||
|
||||
def _setup(self) -> GraphExecutor:
|
||||
"""
|
||||
Set up the executor with all components (runtime, LLM, tools).
|
||||
|
||||
Returns:
|
||||
An initialized GraphExecutor instance.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
storage_path = Path.home() / ".hive" / "agents" / "competitive_intel_agent"
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._event_bus = EventBus()
|
||||
self._tool_registry = ToolRegistry()
|
||||
|
||||
mcp_config_path = Path(__file__).parent / "mcp_servers.json"
|
||||
if mcp_config_path.exists():
|
||||
self._tool_registry.load_mcp_config(mcp_config_path)
|
||||
|
||||
llm = LiteLLMProvider(
|
||||
model=self.config.model,
|
||||
api_key=self.config.api_key,
|
||||
api_base=self.config.api_base,
|
||||
)
|
||||
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
|
||||
self._graph = self._build_graph()
|
||||
runtime = Runtime(storage_path)
|
||||
|
||||
self._executor = GraphExecutor(
|
||||
runtime=runtime,
|
||||
llm=llm,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
event_bus=self._event_bus,
|
||||
storage_path=storage_path,
|
||||
loop_config=self._graph.loop_config,
|
||||
)
|
||||
|
||||
return self._executor
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Set up the agent (initialize executor and tools)."""
|
||||
if self._executor is None:
|
||||
self._setup()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Clean up resources."""
|
||||
self._executor = None
|
||||
self._event_bus = None
|
||||
|
||||
async def trigger_and_wait(
|
||||
self,
|
||||
entry_point: str,
|
||||
input_data: dict[str, Any],
|
||||
timeout: float | None = None,
|
||||
session_state: dict[str, Any] | None = None,
|
||||
) -> ExecutionResult | None:
|
||||
"""
|
||||
Execute the graph and wait for completion.
|
||||
|
||||
Args:
|
||||
entry_point: The graph entry point to trigger.
|
||||
input_data: Data to pass to the entry node.
|
||||
timeout: Optional execution timeout.
|
||||
session_state: Optional initial session state.
|
||||
|
||||
Returns:
|
||||
The execution result, or None if it timed out.
|
||||
"""
|
||||
if self._executor is None:
|
||||
raise RuntimeError("Agent not started. Call start() first.")
|
||||
if self._graph is None:
|
||||
raise RuntimeError("Graph not built. Call start() first.")
|
||||
|
||||
return await self._executor.execute(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
input_data=input_data,
|
||||
session_state=session_state,
|
||||
)
|
||||
|
||||
async def run(self, context: dict[str, Any], session_state: dict[str, Any] | None = None) -> ExecutionResult:
|
||||
"""
|
||||
Run the agent (convenience method for single execution).
|
||||
|
||||
Args:
|
||||
context: The input context for the agent.
|
||||
session_state: Optional initial session state.
|
||||
|
||||
Returns:
|
||||
The final execution result.
|
||||
"""
|
||||
await self.start()
|
||||
try:
|
||||
result = await self.trigger_and_wait(
|
||||
"start", context, session_state=session_state
|
||||
)
|
||||
return result or ExecutionResult(success=False, error="Execution timeout")
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
def info(self) -> dict[str, Any]:
|
||||
"""Get agent information for introspection."""
|
||||
return {
|
||||
"name": metadata.name,
|
||||
"version": metadata.version,
|
||||
"description": metadata.description,
|
||||
"goal": {
|
||||
"name": self.goal.name,
|
||||
"description": self.goal.description,
|
||||
},
|
||||
"nodes": [n.id for n in self.nodes],
|
||||
"edges": [e.id for e in self.edges],
|
||||
"entry_node": self.entry_node,
|
||||
"entry_points": self.entry_points,
|
||||
"pause_nodes": self.pause_nodes,
|
||||
"terminal_nodes": self.terminal_nodes,
|
||||
"client_facing_nodes": [n.id for n in self.nodes if n.client_facing],
|
||||
}
|
||||
|
||||
def validate(self) -> dict[str, Any]:
|
||||
"""
|
||||
Validate agent structure for cycles, missing nodes, or invalid edges.
|
||||
|
||||
Returns:
|
||||
A dict with 'valid' (bool), 'errors' (list), and 'warnings' (list).
|
||||
"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
node_ids = {node.id for node in self.nodes}
|
||||
for edge in self.edges:
|
||||
if edge.source not in node_ids:
|
||||
errors.append(f"Edge {edge.id}: source '{edge.source}' not found")
|
||||
if edge.target not in node_ids:
|
||||
errors.append(f"Edge {edge.id}: target '{edge.target}' not found")
|
||||
|
||||
if self.entry_node not in node_ids:
|
||||
errors.append(f"Entry node '{self.entry_node}' not found")
|
||||
|
||||
for terminal in self.terminal_nodes:
|
||||
if terminal not in node_ids:
|
||||
errors.append(f"Terminal node '{terminal}' not found")
|
||||
|
||||
for ep_id, node_id in self.entry_points.items():
|
||||
if node_id not in node_ids:
|
||||
errors.append(
|
||||
f"Entry point '{ep_id}' references unknown node '{node_id}'"
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
# Create default instance
|
||||
default_agent: CompetitiveIntelAgent = CompetitiveIntelAgent()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Runtime configuration for Competitive Intelligence Agent."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from framework.config import RuntimeConfig
|
||||
|
||||
default_config: RuntimeConfig = RuntimeConfig()
|
||||
|
||||
@dataclass
|
||||
class AgentMetadata:
|
||||
"""Metadata for the Competitive Intelligence Agent."""
|
||||
name: str = "Competitive Intelligence Agent"
|
||||
version: str = "1.0.0"
|
||||
description: str = (
|
||||
"Monitors competitor websites, news sources, and GitHub repositories "
|
||||
"to deliver automated weekly digests with key insights and trend analysis "
|
||||
"for product and marketing teams."
|
||||
)
|
||||
intro_message: str = (
|
||||
"Hi! I'm your competitive intelligence assistant. Tell me which competitors "
|
||||
"to monitor and what areas to focus on (pricing, features, hiring, partnerships, etc.) "
|
||||
"and I'll research them across websites, news, and GitHub to produce a detailed digest."
|
||||
)
|
||||
|
||||
metadata: AgentMetadata = AgentMetadata()
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"hive-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"python",
|
||||
"mcp_server.py",
|
||||
"--stdio"
|
||||
],
|
||||
"cwd": "../../../tools",
|
||||
"description": "Hive tools MCP server providing web_search, web_scrape, github tools, and file utilities"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Node definitions for Competitive Intelligence Agent."""
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
|
||||
# Node 1: Intake (client-facing)
|
||||
intake_node: NodeSpec = NodeSpec(
|
||||
id="intake",
|
||||
name="Competitor Intake",
|
||||
description="Collect competitor list, focus areas, and report preferences from the user",
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
input_keys=["competitors_input"],
|
||||
output_keys=["competitors", "focus_areas", "report_frequency", "has_github_competitors"],
|
||||
system_prompt="""\
|
||||
You are a competitive intelligence intake specialist. Your job is to gather the
|
||||
information needed to run a competitive analysis.
|
||||
|
||||
**STEP 1 — Read the input and respond (text only, NO tool calls):**
|
||||
|
||||
The user may provide input in several forms:
|
||||
- A JSON object with "competitors", "focus_areas", and "report_frequency"
|
||||
- A natural-language description of competitors to track
|
||||
- Just company names
|
||||
|
||||
If the input is clear, confirm what you understood and ask the user to confirm.
|
||||
If it's vague, ask 1-2 clarifying questions:
|
||||
- Which competitors? (name + website URL at minimum)
|
||||
- What focus areas? (pricing, features, hiring, partnerships, messaging, etc.)
|
||||
- Do any competitors have public GitHub organizations/repos?
|
||||
|
||||
After your message, call ask_user() to wait for the user's response.
|
||||
|
||||
**STEP 2 — After the user confirms, call set_output for each key:**
|
||||
|
||||
Structure the data and set outputs:
|
||||
- set_output("competitors", <JSON list of {name, website, github (or null)}>)
|
||||
- set_output("focus_areas", <JSON list of strings like ["pricing", "features", "hiring"]>)
|
||||
- set_output("report_frequency", "weekly")
|
||||
- set_output("has_github_competitors", "true" or "false")
|
||||
|
||||
Set has_github_competitors to "true" if at least one competitor has a non-null github field.
|
||||
""",
|
||||
tools=[],
|
||||
)
|
||||
|
||||
# Node 2: Web Scraper
|
||||
web_scraper_node: NodeSpec = NodeSpec(
|
||||
id="web-scraper",
|
||||
name="Website Monitor",
|
||||
description="Scrape competitor websites for pricing, features, and announcements",
|
||||
node_type="event_loop",
|
||||
input_keys=["competitors", "focus_areas"],
|
||||
output_keys=["web_findings"],
|
||||
system_prompt="""\
|
||||
You are a web intelligence agent. For each competitor, systematically check their
|
||||
online presence for updates related to the focus areas.
|
||||
|
||||
**Process for each competitor:**
|
||||
1. Use web_search to find their current pricing page, product page, changelog,
|
||||
and blog. Try queries like:
|
||||
- "{competitor_name} pricing"
|
||||
- "{competitor_name} changelog OR release notes OR what's new"
|
||||
- "{competitor_name} blog announcements"
|
||||
- "site:{competitor_website} pricing OR features"
|
||||
|
||||
2. Use web_scrape on the most relevant URLs to extract actual content.
|
||||
Focus on: pricing tiers, feature lists, recent announcements, messaging.
|
||||
|
||||
3. For each finding, note:
|
||||
- competitor: which competitor
|
||||
- category: pricing / features / announcement / messaging / other
|
||||
- update: what changed or what you found
|
||||
- source: the URL
|
||||
- date: when it was published/updated (if available, otherwise "unknown")
|
||||
|
||||
**Important:**
|
||||
- Work through competitors one at a time
|
||||
- Skip URLs that fail to load; move on
|
||||
- Prioritize recent content (last 7-30 days)
|
||||
- Be factual — only report what you actually see on the page
|
||||
|
||||
When done, call:
|
||||
- set_output("web_findings", <JSON list of finding objects>)
|
||||
""",
|
||||
tools=["web_search", "web_scrape"],
|
||||
)
|
||||
|
||||
# Node 3: News Search
|
||||
news_search_node: NodeSpec = NodeSpec(
|
||||
id="news-search",
|
||||
name="News & Press Monitor",
|
||||
description="Search for competitor mentions in news, press releases, and industry publications",
|
||||
node_type="event_loop",
|
||||
input_keys=["competitors", "focus_areas"],
|
||||
output_keys=["news_findings"],
|
||||
system_prompt="""\
|
||||
You are a news intelligence agent. Search for recent news, press releases, and
|
||||
industry coverage about each competitor.
|
||||
|
||||
**Process for each competitor:**
|
||||
1. Use web_search with news-focused queries:
|
||||
- "{competitor_name} news"
|
||||
- "{competitor_name} press release 2026"
|
||||
- "{competitor_name} partnership OR acquisition OR funding"
|
||||
- "{competitor_name} {focus_area}" for each focus area
|
||||
|
||||
2. Use web_scrape on the most relevant news articles (aim for 2-3 per competitor).
|
||||
Extract the headline, key details, and publication date.
|
||||
|
||||
3. For each finding, note:
|
||||
- competitor: which competitor
|
||||
- category: partnership / funding / hiring / press_release / industry_news
|
||||
- update: summary of the news item
|
||||
- source: the article URL
|
||||
- date: publication date
|
||||
|
||||
**Important:**
|
||||
- Prioritize news from the last 7 days, but include last 30 days if sparse
|
||||
- Include press releases, blog posts, and industry analyst coverage
|
||||
- Skip paywalled content gracefully
|
||||
- Do NOT fabricate news — only report what you find
|
||||
|
||||
When done, call:
|
||||
- set_output("news_findings", <JSON list of finding objects>)
|
||||
""",
|
||||
tools=["web_search", "web_scrape"],
|
||||
)
|
||||
|
||||
# Node 4: GitHub Monitor
|
||||
github_monitor_node: NodeSpec = NodeSpec(
|
||||
id="github-monitor",
|
||||
name="GitHub Activity Monitor",
|
||||
description="Track public GitHub repository activity for competitors with GitHub presence",
|
||||
node_type="event_loop",
|
||||
input_keys=["competitors"],
|
||||
output_keys=["github_findings"],
|
||||
system_prompt="""\
|
||||
You are a GitHub intelligence agent. For each competitor that has a GitHub
|
||||
organization or username, check their recent public activity.
|
||||
|
||||
**Process for each competitor with a GitHub handle:**
|
||||
1. Use github_get_repo or github_list_repos to find their main repositories.
|
||||
2. Note key metrics:
|
||||
- New repositories created recently
|
||||
- Star count changes (if you have historical data)
|
||||
- Recent commit activity (last 7 days)
|
||||
- Open issues/PRs count
|
||||
- Any new releases or tags
|
||||
|
||||
3. For each notable finding, note:
|
||||
- competitor: which competitor
|
||||
- category: github_activity / new_repo / release / open_source
|
||||
- update: what you found (e.g. "3 new commits to main repo", "Released v2.1")
|
||||
- source: GitHub URL
|
||||
- date: date of activity
|
||||
|
||||
**Important:**
|
||||
- Only process competitors that have a non-null "github" field
|
||||
- Focus on activity that signals product direction or engineering investment
|
||||
- If a competitor has many repos, focus on the most starred / most active ones
|
||||
- If no GitHub tool is available or auth fails, set output with an empty list
|
||||
|
||||
When done, call:
|
||||
- set_output("github_findings", <JSON list of finding objects>)
|
||||
""",
|
||||
tools=["github_list_repos", "github_get_repo", "github_search_repos"],
|
||||
)
|
||||
|
||||
# Node 5: Aggregator
|
||||
aggregator_node: NodeSpec = NodeSpec(
|
||||
id="aggregator",
|
||||
name="Data Aggregator",
|
||||
description="Combine findings from all sources, deduplicate, and structure for analysis",
|
||||
node_type="event_loop",
|
||||
input_keys=["competitors", "web_findings", "news_findings", "github_findings"],
|
||||
output_keys=["aggregated_findings"],
|
||||
nullable_output_keys=["github_findings"],
|
||||
system_prompt="""\
|
||||
You are a data aggregation specialist. Combine all the findings from the web
|
||||
scraper, news search, and GitHub monitor into a single, clean dataset.
|
||||
|
||||
**Steps:**
|
||||
1. Merge all findings into one list, preserving the source attribution.
|
||||
2. Deduplicate: if the same update appears from multiple searches, keep the
|
||||
most detailed version and note multiple sources.
|
||||
3. Categorize each finding consistently using these categories:
|
||||
- pricing, features, partnership, hiring, funding, press_release,
|
||||
- github_activity, messaging, product_launch, other
|
||||
4. Sort findings by competitor, then by date (most recent first).
|
||||
5. Save the aggregated data for historical tracking:
|
||||
save_data(filename="findings_latest.json", data=<aggregated JSON>)
|
||||
|
||||
When done, call:
|
||||
- set_output("aggregated_findings", <JSON list of deduplicated finding objects>)
|
||||
|
||||
Each finding should have: competitor, category, update, source, date.
|
||||
""",
|
||||
tools=["save_data", "load_data", "list_data_files"],
|
||||
)
|
||||
|
||||
# Node 6: Analysis
|
||||
analysis_node: NodeSpec = NodeSpec(
|
||||
id="analysis",
|
||||
name="Insight Analysis",
|
||||
description="Extract key insights, detect trends, and compare with historical data",
|
||||
node_type="event_loop",
|
||||
input_keys=["aggregated_findings", "competitors", "focus_areas"],
|
||||
output_keys=["key_highlights", "trend_analysis", "detailed_findings"],
|
||||
system_prompt="""\
|
||||
You are a competitive intelligence analyst. Analyze the aggregated findings and
|
||||
produce actionable insights.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Load historical data** (if available):
|
||||
- Use list_data_files() to see past snapshots
|
||||
- Use load_data() to load the most recent previous snapshot
|
||||
- Compare current findings with previous data to identify CHANGES
|
||||
|
||||
2. **Extract Key Highlights** (the most important 3-5 items):
|
||||
- Significant pricing changes
|
||||
- Major feature launches or product updates
|
||||
- Strategic moves (partnerships, acquisitions, funding)
|
||||
- Anything that requires immediate attention
|
||||
|
||||
3. **Trend Analysis** (30-day view):
|
||||
- Is a competitor investing more in enterprise features?
|
||||
- Are multiple competitors moving in the same direction?
|
||||
- Any shifts in pricing strategy across the market?
|
||||
- Engineering investment signals from GitHub activity
|
||||
|
||||
4. **Save current snapshot for future comparison:**
|
||||
save_data(filename="snapshot_YYYY-MM-DD.json", data=<current findings + analysis>)
|
||||
|
||||
When done, call:
|
||||
- set_output("key_highlights", <JSON list of highlight strings>)
|
||||
- set_output("trend_analysis", <JSON list of trend observation strings>)
|
||||
- set_output("detailed_findings", <JSON: per-competitor structured findings>)
|
||||
""",
|
||||
tools=["load_data", "save_data", "list_data_files"],
|
||||
)
|
||||
|
||||
# Node 7: Report Generator (client-facing)
|
||||
report_node: NodeSpec = NodeSpec(
|
||||
id="report",
|
||||
name="Report Generator",
|
||||
description="Generate and deliver the competitive intelligence digest as an HTML report",
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
input_keys=["key_highlights", "trend_analysis", "detailed_findings", "competitors"],
|
||||
output_keys=["delivery_status"],
|
||||
system_prompt="""\
|
||||
You are a report generation specialist. Create a polished, self-contained HTML
|
||||
competitive intelligence report and deliver it to the user.
|
||||
|
||||
**STEP 1 — Build the HTML report (tool calls, NO text to user yet):**
|
||||
|
||||
Create a complete, well-styled HTML document. Use this structure:
|
||||
|
||||
```html
|
||||
<h1>Competitive Intelligence Report</h1>
|
||||
<p>Week of [date range]</p>
|
||||
|
||||
<h2>🔥 Key Highlights</h2>
|
||||
<!-- Bulleted list of the most important findings -->
|
||||
|
||||
<h2>📊 Detailed Findings</h2>
|
||||
<!-- For each competitor: -->
|
||||
<h3>[Competitor Name]</h3>
|
||||
<table>
|
||||
<tr><th>Category</th><th>Update</th><th>Source</th><th>Date</th></tr>
|
||||
<!-- One row per finding -->
|
||||
</table>
|
||||
|
||||
<h2>📈 30-Day Trends</h2>
|
||||
<!-- Bulleted list of trend observations -->
|
||||
|
||||
<footer>Generated by Competitive Intelligence Agent</footer>
|
||||
```
|
||||
|
||||
Design requirements:
|
||||
- Modern, readable styling with a dark header and clean tables
|
||||
- Color-coded categories (pricing=blue, features=green, partnerships=purple, etc.)
|
||||
- Clickable source links
|
||||
- Responsive layout
|
||||
|
||||
Save the report:
|
||||
save_data(filename="report_YYYY-MM-DD.html", data=<your_html>)
|
||||
|
||||
Serve it to the user:
|
||||
serve_file_to_user(filename="report_YYYY-MM-DD.html", label="Competitive Intelligence Report")
|
||||
|
||||
**STEP 2 — Present to the user (text only, NO tool calls):**
|
||||
|
||||
Tell the user the report is ready and include the file link. Provide a brief
|
||||
summary of the most important findings. Ask if they want to:
|
||||
- Dig deeper into any specific competitor
|
||||
- Adjust focus areas for next time
|
||||
- See historical trends
|
||||
|
||||
After presenting, call ask_user() to wait for the user's response.
|
||||
|
||||
**STEP 3 — After the user responds:**
|
||||
- Answer follow-up questions from the research material
|
||||
- Call ask_user() again if they might have more questions
|
||||
- When satisfied: set_output("delivery_status", "completed")
|
||||
""",
|
||||
tools=["save_data", "load_data", "serve_file_to_user", "list_data_files"],
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"intake_node",
|
||||
"web_scraper_node",
|
||||
"news_search_node",
|
||||
"github_monitor_node",
|
||||
"aggregator_node",
|
||||
"analysis_node",
|
||||
"report_node",
|
||||
]
|
||||
@@ -9,11 +9,13 @@ Run the agent using the following command:
|
||||
### Linux / Mac
|
||||
```bash
|
||||
PYTHONPATH=core:examples/templates python -m deep_research_agent run --mock --topic "Artificial Intelligence"
|
||||
```
|
||||
|
||||
### Windows
|
||||
```powershell
|
||||
$env:PYTHONPATH="core;examples\templates"
|
||||
python -m deep_research_agent run --mock --topic "Artificial Intelligence"
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ Call gmail_list_labels() to show the user their current Gmail labels. This helps
|
||||
|
||||
- set_output("rules", <the confirmed rules as a clear text description>)
|
||||
- set_output("max_emails", <the confirmed max_emails as a string number, e.g. "100">)
|
||||
|
||||
""",
|
||||
tools=["gmail_list_labels"],
|
||||
)
|
||||
@@ -71,23 +72,25 @@ fetch_emails_node = NodeSpec(
|
||||
You are a data pipeline step. Your job is to fetch emails from Gmail and write them to emails.jsonl.
|
||||
|
||||
**FIRST-TIME FETCH (default path):**
|
||||
1. Read "max_emails" from input context.
|
||||
1. Read "max_emails" and "rules" from input context.
|
||||
2. Call bulk_fetch_emails(max_emails=<value>).
|
||||
3. The tool returns {"filename": "emails.jsonl"}.
|
||||
4. Call set_output("emails", "emails.jsonl").
|
||||
|
||||
**NEXT-BATCH FETCH (when user asks for "the next N" emails):**
|
||||
The user wants emails BEYOND what was already fetched. Use pagination:
|
||||
1. Call gmail_list_messages(query="label:INBOX", max_results=<previous + new count>) to get message IDs. Use page_token if needed to paginate past already-fetched emails.
|
||||
2. Identify message IDs NOT in the previous batch (you remember them from continuous conversation).
|
||||
3. Call gmail_batch_get_messages(message_ids=<new_ids>, format="metadata") for full metadata.
|
||||
4. For each message in the result, call append_data(filename="emails.jsonl", data=<JSON: {id, subject, from, to, date, snippet, labels}>).
|
||||
1. Call gmail_list_messages(query="label:INBOX", max_results=<previous + new count>).
|
||||
Use page_token if needed to paginate past already-fetched emails.
|
||||
2. Identify message IDs NOT in the previous batch.
|
||||
3. Call gmail_batch_get_messages(message_ids=<new_ids>, format="metadata").
|
||||
4. For each message, call append_data(filename="emails.jsonl",
|
||||
data=<JSON: {id, subject, from, to, date, snippet, labels}>).
|
||||
5. Call set_output("emails", "emails.jsonl").
|
||||
|
||||
**TOOLS:**
|
||||
- bulk_fetch_emails(max_emails) — Bulk fetch from inbox, writes emails.jsonl. Use for first fetch.
|
||||
- gmail_list_messages(query, max_results, page_token) — List message IDs with pagination. Returns {messages, next_page_token}.
|
||||
- gmail_batch_get_messages(message_ids, format) — Fetch metadata for specific IDs (max 50 per call).
|
||||
- bulk_fetch_emails(max_emails) — Bulk fetch from inbox, writes emails.jsonl.
|
||||
- gmail_list_messages(query, max_results, page_token) — List message IDs.
|
||||
- gmail_batch_get_messages(message_ids, format) — Fetch metadata (max 50/call).
|
||||
- append_data(filename, data) — Append a line to a JSONL file.
|
||||
|
||||
Do NOT add commentary or explanation. Execute the appropriate path and call set_output when done.
|
||||
@@ -118,19 +121,20 @@ classify_and_act_node = NodeSpec(
|
||||
You are an inbox management assistant. Apply the user's rules to their emails and execute Gmail actions.
|
||||
|
||||
**YOUR TOOLS:**
|
||||
- load_data(filename, limit, offset) — Read emails from a local file. This is how you access the emails.
|
||||
- append_data(filename, data) — Append a line to a file. Use this to record actions taken.
|
||||
- gmail_batch_modify_messages(message_ids, add_labels, remove_labels) — Modify Gmail labels in batch. ALWAYS prefer this.
|
||||
- load_data(filename, limit, offset) — Read emails from a local file.
|
||||
- append_data(filename, data) — Append a line to a file. Record actions taken.
|
||||
- gmail_batch_modify_messages(message_ids, add_labels, remove_labels) — Modify labels in batch. ALWAYS prefer this.
|
||||
- gmail_modify_message(message_id, add_labels, remove_labels) — Modify a single message's labels.
|
||||
- gmail_trash_message(message_id) — Move a message to trash. No batch version; call per email.
|
||||
- gmail_trash_message(message_id) — Move a message to trash.
|
||||
- gmail_create_draft(to, subject, body) — Create a draft reply. NEVER sends automatically.
|
||||
- gmail_create_label(name) — Create a new Gmail label. Returns the label ID.
|
||||
- gmail_list_labels() — List all existing Gmail labels with their IDs.
|
||||
- set_output(key, value) — Set an output value. Call ONLY after all actions are executed.
|
||||
|
||||
**CONTEXT:**
|
||||
- "rules" = the user's rule to apply (e.g. "mark all as unread")
|
||||
- "emails" = a filename (e.g. "emails.jsonl") containing the fetched emails as JSONL. Each line has: id, subject, from, to, date, snippet, labels.
|
||||
- "rules" = the user's rule to apply (e.g. "mark all as unread").
|
||||
- "emails" = a filename (e.g. "emails.jsonl") containing the fetched emails as JSONL.
|
||||
Each line has: id, subject, from, to, date, snippet, labels.
|
||||
|
||||
**PROCESS EMAILS ONE CHUNK AT A TIME (you will get multiple turns):**
|
||||
|
||||
|
||||
@@ -41,6 +41,13 @@ TOOLS = {
|
||||
"type": "string",
|
||||
"description": "Maximum number of emails to fetch (default '100')",
|
||||
},
|
||||
"account": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Account alias to use (e.g. 'timothy-home'). "
|
||||
"Required when multiple Google accounts are connected."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
@@ -64,8 +71,13 @@ def _get_data_dir() -> str:
|
||||
return ctx["data_dir"]
|
||||
|
||||
|
||||
def _get_access_token() -> str:
|
||||
"""Get Google OAuth access token from credential store."""
|
||||
def _get_access_token(account: str = "") -> str:
|
||||
"""Get Google OAuth access token from credential store.
|
||||
|
||||
Args:
|
||||
account: Account alias (e.g. 'timothy-home'). When provided,
|
||||
resolves the token for that specific account.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Try credential store first (same pattern as gmail_tool.py)
|
||||
@@ -73,7 +85,10 @@ def _get_access_token() -> str:
|
||||
from aden_tools.credentials import CredentialStoreAdapter
|
||||
|
||||
credentials = CredentialStoreAdapter.default()
|
||||
token = credentials.get("google")
|
||||
if account:
|
||||
token = credentials.get_by_alias("google", account)
|
||||
else:
|
||||
token = credentials.get("google")
|
||||
if token:
|
||||
return token
|
||||
except Exception:
|
||||
@@ -105,17 +120,21 @@ def _parse_headers(headers: list[dict]) -> dict[str, str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bulk_fetch_emails(max_emails: str = "100") -> str:
|
||||
def _bulk_fetch_emails(max_emails: str = "100", account: str = "") -> str:
|
||||
"""Fetch inbox emails and write them to emails.jsonl.
|
||||
|
||||
Uses synchronous httpx.Client since this runs as a tool call inside
|
||||
an already-running async event loop.
|
||||
|
||||
Args:
|
||||
max_emails: Maximum number of emails to fetch.
|
||||
account: Account alias (e.g. 'timothy-home') for multi-account routing.
|
||||
|
||||
Returns:
|
||||
The filename "emails.jsonl" (written to session data_dir).
|
||||
"""
|
||||
max_count = int(max_emails) if max_emails else 100
|
||||
access_token = _get_access_token()
|
||||
access_token = _get_access_token(account)
|
||||
data_dir = _get_data_dir()
|
||||
Path(data_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -237,7 +256,8 @@ def tool_executor(tool_use: ToolUse) -> ToolResult:
|
||||
if tool_use.name == "bulk_fetch_emails":
|
||||
try:
|
||||
max_emails = tool_use.input.get("max_emails", "100")
|
||||
filename = _bulk_fetch_emails(max_emails=max_emails)
|
||||
account = tool_use.input.get("account", "")
|
||||
filename = _bulk_fetch_emails(max_emails=max_emails, account=account)
|
||||
return ToolResult(
|
||||
tool_use_id=tool_use.id,
|
||||
content=json.dumps({"filename": filename}),
|
||||
|
||||
+25
-11
@@ -748,9 +748,14 @@ if [ ${#FOUND_PROVIDERS[@]} -gt 0 ]; then
|
||||
echo -e " ${CYAN}$i)${NC} $provider"
|
||||
i=$((i + 1))
|
||||
done
|
||||
ZAI_CHOICE=$i
|
||||
echo -e " ${CYAN}$i)${NC} ZAI Code Subscription ${DIM}(use your ZAI Code plan)${NC}"
|
||||
i=$((i + 1))
|
||||
# Only show ZAI Code Subscription if the API key already exists
|
||||
if [ -n "${ZAI_API_KEY:-}" ]; then
|
||||
ZAI_CHOICE=$i
|
||||
echo -e " ${CYAN}$i)${NC} ZAI Code Subscription ${DIM}(use your ZAI Code plan)${NC}"
|
||||
i=$((i + 1))
|
||||
else
|
||||
ZAI_CHOICE=-1 # invalid choice, won't match
|
||||
fi
|
||||
echo -e " ${CYAN}$i)${NC} Other"
|
||||
max_choice=$i
|
||||
echo ""
|
||||
@@ -1203,18 +1208,27 @@ if [ "$CODEX_AVAILABLE" = true ]; then
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Prompt user to source shell config or start new terminal
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BOLD}⚠️ IMPORTANT: Load your new configuration${NC}"
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo -e " Your API keys have been saved to ${CYAN}$SHELL_RC_FILE${NC}"
|
||||
echo -e " To use them, either:"
|
||||
echo ""
|
||||
echo -e " ${GREEN}Option 1:${NC} Source your shell config now:"
|
||||
echo -e " ${CYAN}source $SHELL_RC_FILE${NC}"
|
||||
echo ""
|
||||
echo -e " ${GREEN}Option 2:${NC} Open a new terminal window"
|
||||
echo ""
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BOLD}Run an Agent:${NC}"
|
||||
echo ""
|
||||
echo -e " Launch the interactive dashboard to browse and run agents:"
|
||||
echo -e " You can start a example agent or an agent built by yourself:"
|
||||
echo -e " You can start an example agent or an agent built by yourself:"
|
||||
echo -e " ${CYAN}hive tui${NC}"
|
||||
echo ""
|
||||
# Show shell sourcing reminder if we added environment variables
|
||||
if [ -n "$SELECTED_PROVIDER_ID" ] || [ -n "$HIVE_CREDENTIAL_KEY" ]; then
|
||||
echo -e "${BOLD}Note:${NC} To use the new environment variables in this shell, run:"
|
||||
echo -e " ${CYAN}source $SHELL_RC_FILE${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e "${DIM}Run ./quickstart.sh again to reconfigure.${NC}"
|
||||
echo ""
|
||||
|
||||
@@ -102,10 +102,12 @@ python mcp_server.py
|
||||
| ---- | ----------- |
|
||||
| `web_search` | Search the web (Google or Brave, auto-detected) |
|
||||
| `web_scrape` | Scrape and extract content from webpages |
|
||||
| `search_wikipedia` | Search Wikipedia for pages and summaries |
|
||||
| `scholar_search`, `scholar_get_citations`, `scholar_get_author` | Search academic papers, get citations and author profiles via SerpAPI |
|
||||
| `patents_search`, `patents_get_details` | Search patents and retrieve patent details via SerpAPI |
|
||||
| `exa_search`, `exa_answer`, `exa_find_similar`, `exa_get_contents` | Semantic search and content retrieval via Exa AI |
|
||||
| `news_search`, `news_headlines`, `news_by_company`, `news_sentiment` | Search news articles and analyse sentiment |
|
||||
| `search_papers`, `download_paper` | Search arXiv for scientific papers and download PDFs |
|
||||
|
||||
### Communication
|
||||
|
||||
@@ -184,6 +186,7 @@ tools/
|
||||
│ ├── web_search_tool/
|
||||
│ ├── web_scrape_tool/
|
||||
│ ├── pdf_read_tool/
|
||||
│ ├── wikipedia_tool/
|
||||
│ ├── time_tool/
|
||||
│ └── calendar_tool/
|
||||
├── tests/ # Test suite
|
||||
|
||||
@@ -33,6 +33,9 @@ dependencies = [
|
||||
"resend>=2.0.0",
|
||||
"framework",
|
||||
"stripe>=14.3.0",
|
||||
"arxiv>=2.1.0",
|
||||
"requests>=2.31.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -56,6 +56,7 @@ To add a new credential:
|
||||
from .apollo import APOLLO_CREDENTIALS
|
||||
from .base import CredentialError, CredentialSpec
|
||||
from .bigquery import BIGQUERY_CREDENTIALS
|
||||
from .brevo import BREVO_CREDENTIALS
|
||||
from .browser import get_aden_auth_url, get_aden_setup_url, open_browser
|
||||
from .calcom import CALCOM_CREDENTIALS
|
||||
from .discord import DISCORD_CREDENTIALS
|
||||
@@ -65,10 +66,16 @@ from .github import GITHUB_CREDENTIALS
|
||||
from .google_calendar import GOOGLE_CALENDAR_CREDENTIALS
|
||||
from .google_docs import GOOGLE_DOCS_CREDENTIALS
|
||||
from .google_maps import GOOGLE_MAPS_CREDENTIALS
|
||||
from .health_check import HealthCheckResult, check_credential_health
|
||||
from .health_check import (
|
||||
BaseHttpHealthChecker,
|
||||
HealthCheckResult,
|
||||
check_credential_health,
|
||||
validate_integration_wiring,
|
||||
)
|
||||
from .hubspot import HUBSPOT_CREDENTIALS
|
||||
from .llm import LLM_CREDENTIALS
|
||||
from .news import NEWS_CREDENTIALS
|
||||
from .postgres import POSTGRES_CREDENTIALS
|
||||
from .razorpay import RAZORPAY_CREDENTIALS
|
||||
from .search import SEARCH_CREDENTIALS
|
||||
from .serpapi import SERPAPI_CREDENTIALS
|
||||
@@ -104,6 +111,8 @@ CREDENTIAL_SPECS = {
|
||||
**BIGQUERY_CREDENTIALS,
|
||||
**CALCOM_CREDENTIALS,
|
||||
**STRIPE_CREDENTIALS,
|
||||
**BREVO_CREDENTIALS,
|
||||
**POSTGRES_CREDENTIALS,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
@@ -114,8 +123,10 @@ __all__ = [
|
||||
# Credential store adapter (replaces deprecated CredentialManager)
|
||||
"CredentialStoreAdapter",
|
||||
# Health check utilities
|
||||
"BaseHttpHealthChecker",
|
||||
"HealthCheckResult",
|
||||
"check_credential_health",
|
||||
"validate_integration_wiring",
|
||||
# Browser utilities for OAuth2 flows
|
||||
"open_browser",
|
||||
"get_aden_auth_url",
|
||||
@@ -147,4 +158,6 @@ __all__ = [
|
||||
"CALCOM_CREDENTIALS",
|
||||
"DISCORD_CREDENTIALS",
|
||||
"STRIPE_CREDENTIALS",
|
||||
"BREVO_CREDENTIALS",
|
||||
"POSTGRES_CREDENTIALS",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Brevo tool credentials.
|
||||
|
||||
Contains credentials for Brevo (formerly Sendinblue) transactional email,
|
||||
SMS, and contact management integration.
|
||||
"""
|
||||
|
||||
from .base import CredentialSpec
|
||||
|
||||
BREVO_CREDENTIALS = {
|
||||
"brevo": CredentialSpec(
|
||||
env_var="BREVO_API_KEY",
|
||||
tools=[
|
||||
"brevo_send_email",
|
||||
"brevo_send_sms",
|
||||
"brevo_create_contact",
|
||||
"brevo_get_contact",
|
||||
"brevo_update_contact",
|
||||
],
|
||||
required=True,
|
||||
startup_required=False,
|
||||
help_url="https://app.brevo.com/settings/keys/api",
|
||||
description="Brevo API key for transactional email, SMS, and contact management",
|
||||
# Auth method support
|
||||
aden_supported=False,
|
||||
direct_api_key_supported=True,
|
||||
api_key_instructions="""To get a Brevo API key:
|
||||
1. Go to https://app.brevo.com and create an account (or sign in)
|
||||
2. Navigate to Settings > API Keys (or visit https://app.brevo.com/settings/keys/api)
|
||||
3. Click "Generate a new API key"
|
||||
4. Give it a name (e.g., "Hive Agent")
|
||||
5. Copy the API key (starts with xkeysib-)
|
||||
6. Store it securely - you won't be able to see it again!
|
||||
7. Note: For sending emails, you'll need a verified sender domain or email""",
|
||||
# Health check configuration
|
||||
health_check_endpoint="https://api.brevo.com/v3/account",
|
||||
health_check_method="GET",
|
||||
# Credential store mapping
|
||||
credential_id="brevo",
|
||||
credential_key="api_key",
|
||||
),
|
||||
}
|
||||
@@ -239,6 +239,178 @@ class OAuthBearerHealthChecker:
|
||||
)
|
||||
|
||||
|
||||
class BaseHttpHealthChecker:
|
||||
"""Configurable base class for HTTP-based credential health checkers.
|
||||
|
||||
Reduces boilerplate by handling the common HTTP request/response/error pattern.
|
||||
Subclasses configure via class constants and override hooks as needed.
|
||||
|
||||
Supports five auth patterns:
|
||||
- AUTH_BEARER: Authorization: Bearer <token>
|
||||
- AUTH_HEADER: Custom header name/value template
|
||||
- AUTH_QUERY: Token as query parameter
|
||||
- AUTH_BASIC: HTTP Basic Authentication
|
||||
- AUTH_URL: Token embedded in URL (e.g., Telegram)
|
||||
|
||||
Example::
|
||||
|
||||
class CalcomHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.cal.com/v1/me"
|
||||
SERVICE_NAME = "Cal.com"
|
||||
AUTH_TYPE = "query"
|
||||
AUTH_QUERY_PARAM_NAME = "apiKey"
|
||||
"""
|
||||
|
||||
# Auth pattern constants
|
||||
AUTH_BEARER = "bearer"
|
||||
AUTH_HEADER = "header"
|
||||
AUTH_QUERY = "query"
|
||||
AUTH_BASIC = "basic"
|
||||
AUTH_URL = "url"
|
||||
|
||||
# Subclass configuration
|
||||
ENDPOINT: str = ""
|
||||
SERVICE_NAME: str = ""
|
||||
HTTP_METHOD: str = "GET"
|
||||
TIMEOUT: float = 10.0
|
||||
|
||||
# Auth configuration
|
||||
AUTH_TYPE: str = AUTH_BEARER
|
||||
AUTH_HEADER_NAME: str = "Authorization"
|
||||
AUTH_HEADER_TEMPLATE: str = "Bearer {token}"
|
||||
AUTH_QUERY_PARAM_NAME: str = "key"
|
||||
|
||||
# Status code interpretation
|
||||
VALID_STATUSES: frozenset[int] = frozenset({200})
|
||||
RATE_LIMITED_STATUSES: frozenset[int] = frozenset({429})
|
||||
AUTHENTICATED_ERROR_STATUSES: frozenset[int] = frozenset()
|
||||
INVALID_STATUSES: frozenset[int] = frozenset({401})
|
||||
FORBIDDEN_STATUSES: frozenset[int] = frozenset({403})
|
||||
|
||||
def _build_url(self, credential_value: str) -> str:
|
||||
"""Build request URL. Override for URL-template auth."""
|
||||
return self.ENDPOINT
|
||||
|
||||
def _build_headers(self, credential_value: str) -> dict[str, str]:
|
||||
"""Build request headers based on AUTH_TYPE."""
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if self.AUTH_TYPE == self.AUTH_BEARER:
|
||||
headers["Authorization"] = f"Bearer {credential_value}"
|
||||
elif self.AUTH_TYPE == self.AUTH_HEADER:
|
||||
headers[self.AUTH_HEADER_NAME] = self.AUTH_HEADER_TEMPLATE.format(
|
||||
token=credential_value
|
||||
)
|
||||
return headers
|
||||
|
||||
def _build_params(self, credential_value: str) -> dict[str, str]:
|
||||
"""Build query parameters. Includes auth param for AUTH_QUERY type."""
|
||||
if self.AUTH_TYPE == self.AUTH_QUERY:
|
||||
return {self.AUTH_QUERY_PARAM_NAME: credential_value}
|
||||
return {}
|
||||
|
||||
def _build_auth(self, credential_value: str) -> tuple[str, str] | None:
|
||||
"""Build HTTP Basic auth tuple for AUTH_BASIC type."""
|
||||
if self.AUTH_TYPE == self.AUTH_BASIC:
|
||||
return (credential_value, "")
|
||||
return None
|
||||
|
||||
def _build_json_body(self, credential_value: str) -> dict | None:
|
||||
"""Build JSON request body. Override for POST requests that need one."""
|
||||
return None
|
||||
|
||||
def _extract_identity(self, data: dict) -> dict[str, str]:
|
||||
"""Extract identity info from successful response. Override in subclass."""
|
||||
return {}
|
||||
|
||||
def _interpret_response(self, response: httpx.Response) -> HealthCheckResult:
|
||||
"""Interpret HTTP response. Override for non-standard status logic."""
|
||||
status = response.status_code
|
||||
|
||||
if status in self.VALID_STATUSES:
|
||||
identity: dict[str, str] = {}
|
||||
try:
|
||||
data = response.json()
|
||||
identity = self._extract_identity(data)
|
||||
except Exception:
|
||||
pass
|
||||
return HealthCheckResult(
|
||||
valid=True,
|
||||
message=f"{self.SERVICE_NAME} credentials valid",
|
||||
details={"identity": identity} if identity else {},
|
||||
)
|
||||
elif status in self.RATE_LIMITED_STATUSES:
|
||||
return HealthCheckResult(
|
||||
valid=True,
|
||||
message=f"{self.SERVICE_NAME} credentials valid (rate limited)",
|
||||
details={"status_code": status, "rate_limited": True},
|
||||
)
|
||||
elif status in self.AUTHENTICATED_ERROR_STATUSES:
|
||||
return HealthCheckResult(
|
||||
valid=True,
|
||||
message=f"{self.SERVICE_NAME} credentials valid",
|
||||
details={"status_code": status},
|
||||
)
|
||||
elif status in self.INVALID_STATUSES:
|
||||
return HealthCheckResult(
|
||||
valid=False,
|
||||
message=f"{self.SERVICE_NAME} credentials are invalid or expired",
|
||||
details={"status_code": status},
|
||||
)
|
||||
elif status in self.FORBIDDEN_STATUSES:
|
||||
return HealthCheckResult(
|
||||
valid=False,
|
||||
message=f"{self.SERVICE_NAME} credentials lack required permissions",
|
||||
details={"status_code": status},
|
||||
)
|
||||
else:
|
||||
return HealthCheckResult(
|
||||
valid=False,
|
||||
message=f"{self.SERVICE_NAME} API returned status {status}",
|
||||
details={"status_code": status},
|
||||
)
|
||||
|
||||
def check(self, credential_value: str) -> HealthCheckResult:
|
||||
"""Execute the health check. Normally not overridden."""
|
||||
try:
|
||||
url = self._build_url(credential_value)
|
||||
headers = self._build_headers(credential_value)
|
||||
params = self._build_params(credential_value)
|
||||
auth = self._build_auth(credential_value)
|
||||
json_body = self._build_json_body(credential_value)
|
||||
|
||||
with httpx.Client(timeout=self.TIMEOUT) as client:
|
||||
kwargs: dict[str, Any] = {"headers": headers}
|
||||
if params:
|
||||
kwargs["params"] = params
|
||||
if auth:
|
||||
kwargs["auth"] = auth
|
||||
if json_body is not None:
|
||||
kwargs["json"] = json_body
|
||||
|
||||
if self.HTTP_METHOD.upper() == "POST":
|
||||
response = client.post(url, **kwargs)
|
||||
else:
|
||||
response = client.get(url, **kwargs)
|
||||
|
||||
return self._interpret_response(response)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return HealthCheckResult(
|
||||
valid=False,
|
||||
message=f"{self.SERVICE_NAME} API request timed out",
|
||||
details={"error": "timeout"},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
error_msg = str(e)
|
||||
if any(s in error_msg for s in ("Bearer", "Authorization", "api_key", "token")):
|
||||
error_msg = "Request failed (details redacted for security)"
|
||||
return HealthCheckResult(
|
||||
valid=False,
|
||||
message=f"Failed to connect to {self.SERVICE_NAME}: {error_msg}",
|
||||
details={"error": error_msg},
|
||||
)
|
||||
|
||||
|
||||
class GoogleCalendarHealthChecker(OAuthBearerHealthChecker):
|
||||
"""Health checker for Google Calendar OAuth tokens."""
|
||||
|
||||
@@ -740,6 +912,152 @@ class GoogleGmailHealthChecker(OAuthBearerHealthChecker):
|
||||
return {"email": email} if email else {}
|
||||
|
||||
|
||||
# --- New checkers using BaseHttpHealthChecker ---
|
||||
|
||||
|
||||
class StripeHealthChecker(BaseHttpHealthChecker):
|
||||
"""Health checker for Stripe API key."""
|
||||
|
||||
ENDPOINT = "https://api.stripe.com/v1/balance"
|
||||
SERVICE_NAME = "Stripe"
|
||||
|
||||
|
||||
class ExaSearchHealthChecker(BaseHttpHealthChecker):
|
||||
"""Health checker for Exa Search API key."""
|
||||
|
||||
ENDPOINT = "https://api.exa.ai/search"
|
||||
SERVICE_NAME = "Exa Search"
|
||||
HTTP_METHOD = "POST"
|
||||
|
||||
def _build_json_body(self, credential_value: str) -> dict:
|
||||
return {"query": "test", "numResults": 1}
|
||||
|
||||
|
||||
class GoogleDocsHealthChecker(OAuthBearerHealthChecker):
|
||||
"""Health checker for Google Docs OAuth tokens."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
endpoint="https://docs.googleapis.com/v1/documents/1",
|
||||
service_name="Google Docs",
|
||||
)
|
||||
|
||||
|
||||
class CalcomHealthChecker(BaseHttpHealthChecker):
|
||||
"""Health checker for Cal.com API key."""
|
||||
|
||||
ENDPOINT = "https://api.cal.com/v1/me"
|
||||
SERVICE_NAME = "Cal.com"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_QUERY
|
||||
AUTH_QUERY_PARAM_NAME = "apiKey"
|
||||
|
||||
|
||||
class SerpApiHealthChecker(BaseHttpHealthChecker):
|
||||
"""Health checker for SerpAPI key."""
|
||||
|
||||
ENDPOINT = "https://serpapi.com/account.json"
|
||||
SERVICE_NAME = "SerpAPI"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_QUERY
|
||||
AUTH_QUERY_PARAM_NAME = "api_key"
|
||||
|
||||
|
||||
class ApolloHealthChecker(BaseHttpHealthChecker):
|
||||
"""Health checker for Apollo.io API key."""
|
||||
|
||||
ENDPOINT = "https://api.apollo.io/v1/auth/health"
|
||||
SERVICE_NAME = "Apollo"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_QUERY
|
||||
AUTH_QUERY_PARAM_NAME = "api_key"
|
||||
|
||||
|
||||
class TelegramHealthChecker(BaseHttpHealthChecker):
|
||||
"""Health checker for Telegram bot token."""
|
||||
|
||||
SERVICE_NAME = "Telegram"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_URL
|
||||
|
||||
def _build_url(self, credential_value: str) -> str:
|
||||
return f"https://api.telegram.org/bot{credential_value}/getMe"
|
||||
|
||||
def _build_headers(self, credential_value: str) -> dict[str, str]:
|
||||
return {"Accept": "application/json"}
|
||||
|
||||
def _interpret_response(self, response: httpx.Response) -> HealthCheckResult:
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
data = response.json()
|
||||
if data.get("ok"):
|
||||
username = data.get("result", {}).get("username", "unknown")
|
||||
identity = {"username": username} if username != "unknown" else {}
|
||||
return HealthCheckResult(
|
||||
valid=True,
|
||||
message=f"Telegram bot token valid (bot: @{username})",
|
||||
details={"identity": identity},
|
||||
)
|
||||
else:
|
||||
return HealthCheckResult(
|
||||
valid=False,
|
||||
message="Telegram bot token is invalid",
|
||||
details={"telegram_error": data.get("description", "")},
|
||||
)
|
||||
except Exception:
|
||||
return HealthCheckResult(
|
||||
valid=True,
|
||||
message="Telegram credentials valid",
|
||||
)
|
||||
elif response.status_code == 401:
|
||||
return HealthCheckResult(
|
||||
valid=False,
|
||||
message="Telegram bot token is invalid",
|
||||
details={"status_code": 401},
|
||||
)
|
||||
else:
|
||||
return HealthCheckResult(
|
||||
valid=False,
|
||||
message=f"Telegram API returned status {response.status_code}",
|
||||
details={"status_code": response.status_code},
|
||||
)
|
||||
|
||||
|
||||
class NewsdataHealthChecker(BaseHttpHealthChecker):
|
||||
"""Health checker for Newsdata.io API key."""
|
||||
|
||||
ENDPOINT = "https://newsdata.io/api/1/news"
|
||||
SERVICE_NAME = "Newsdata"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_QUERY
|
||||
AUTH_QUERY_PARAM_NAME = "apikey"
|
||||
|
||||
def _build_params(self, credential_value: str) -> dict[str, str]:
|
||||
params = super()._build_params(credential_value)
|
||||
params["q"] = "test"
|
||||
return params
|
||||
|
||||
|
||||
class FinlightHealthChecker(BaseHttpHealthChecker):
|
||||
"""Health checker for Finlight API key."""
|
||||
|
||||
ENDPOINT = "https://api.finlight.me/v1/news"
|
||||
SERVICE_NAME = "Finlight"
|
||||
|
||||
|
||||
class BrevoHealthChecker(BaseHttpHealthChecker):
|
||||
"""Health checker for Brevo API key."""
|
||||
|
||||
ENDPOINT = "https://api.brevo.com/v3/account"
|
||||
SERVICE_NAME = "Brevo"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_HEADER
|
||||
AUTH_HEADER_NAME = "api-key"
|
||||
AUTH_HEADER_TEMPLATE = "{token}"
|
||||
|
||||
def _extract_identity(self, data: dict) -> dict[str, str]:
|
||||
identity: dict[str, str] = {}
|
||||
if data.get("email"):
|
||||
identity["email"] = data["email"]
|
||||
if data.get("companyName"):
|
||||
identity["company"] = data["companyName"]
|
||||
return identity
|
||||
|
||||
|
||||
# Registry of health checkers
|
||||
HEALTH_CHECKERS: dict[str, CredentialHealthChecker] = {
|
||||
"discord": DiscordHealthChecker(),
|
||||
@@ -753,6 +1071,16 @@ HEALTH_CHECKERS: dict[str, CredentialHealthChecker] = {
|
||||
"anthropic": AnthropicHealthChecker(),
|
||||
"github": GitHubHealthChecker(),
|
||||
"resend": ResendHealthChecker(),
|
||||
"stripe": StripeHealthChecker(),
|
||||
"exa_search": ExaSearchHealthChecker(),
|
||||
"google_docs": GoogleDocsHealthChecker(),
|
||||
"calcom": CalcomHealthChecker(),
|
||||
"serpapi": SerpApiHealthChecker(),
|
||||
"apollo": ApolloHealthChecker(),
|
||||
"telegram": TelegramHealthChecker(),
|
||||
"newsdata": NewsdataHealthChecker(),
|
||||
"finlight": FinlightHealthChecker(),
|
||||
"brevo": BrevoHealthChecker(),
|
||||
}
|
||||
|
||||
|
||||
@@ -807,3 +1135,80 @@ def check_credential_health(
|
||||
return checker.check(credential_value, kwargs["cse_id"])
|
||||
|
||||
return checker.check(credential_value)
|
||||
|
||||
|
||||
def validate_integration_wiring(credential_name: str) -> list[str]:
|
||||
"""Check that a credential integration is fully wired up.
|
||||
|
||||
Returns a list of issues found. Empty list means everything is correct.
|
||||
|
||||
Use during development to verify a new integration has all required pieces:
|
||||
CredentialSpec, health checker, endpoint consistency, and required fields.
|
||||
|
||||
Args:
|
||||
credential_name: The credential name to validate (e.g., 'jira').
|
||||
|
||||
Returns:
|
||||
List of issue descriptions. Empty if fully wired.
|
||||
|
||||
Example::
|
||||
|
||||
issues = validate_integration_wiring("stripe")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
"""
|
||||
from . import CREDENTIAL_SPECS
|
||||
|
||||
issues: list[str] = []
|
||||
|
||||
# 1. Check spec exists
|
||||
spec = CREDENTIAL_SPECS.get(credential_name)
|
||||
if spec is None:
|
||||
issues.append(
|
||||
f"No CredentialSpec for '{credential_name}' in CREDENTIAL_SPECS. "
|
||||
f"Add it to the appropriate category file and import in __init__.py."
|
||||
)
|
||||
return issues
|
||||
|
||||
# 2. Check required fields
|
||||
if not spec.env_var:
|
||||
issues.append("CredentialSpec.env_var is empty")
|
||||
if not spec.description:
|
||||
issues.append("CredentialSpec.description is empty")
|
||||
if not spec.tools and not spec.node_types:
|
||||
issues.append("CredentialSpec has no tools or node_types")
|
||||
if not spec.help_url:
|
||||
issues.append("CredentialSpec.help_url is empty (users need this to get credentials)")
|
||||
if spec.direct_api_key_supported and not spec.api_key_instructions:
|
||||
issues.append(
|
||||
"CredentialSpec.api_key_instructions is empty but direct_api_key_supported=True"
|
||||
)
|
||||
|
||||
# 3. Check health check
|
||||
if not spec.health_check_endpoint:
|
||||
issues.append(
|
||||
"CredentialSpec.health_check_endpoint is empty. "
|
||||
"Add a lightweight API endpoint for credential validation."
|
||||
)
|
||||
else:
|
||||
checker = HEALTH_CHECKERS.get(credential_name)
|
||||
if checker is None:
|
||||
issues.append(
|
||||
f"No entry in HEALTH_CHECKERS for '{credential_name}'. "
|
||||
f"The OAuthBearerHealthChecker fallback will be used. "
|
||||
f"Add a dedicated checker if auth is not Bearer token."
|
||||
)
|
||||
else:
|
||||
checker_endpoint = getattr(checker, "ENDPOINT", None) or getattr(
|
||||
checker, "endpoint", None
|
||||
)
|
||||
if checker_endpoint and spec.health_check_endpoint:
|
||||
spec_base = spec.health_check_endpoint.split("?")[0]
|
||||
checker_base = str(checker_endpoint).split("?")[0]
|
||||
if spec_base != checker_base:
|
||||
issues.append(
|
||||
f"Endpoint mismatch: spec='{spec.health_check_endpoint}' "
|
||||
f"vs checker='{checker_endpoint}'"
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
PostgreSQL tool credentials.
|
||||
"""
|
||||
|
||||
from .base import CredentialSpec
|
||||
|
||||
POSTGRES_CREDENTIALS = {
|
||||
"postgres": CredentialSpec(
|
||||
env_var="DATABASE_URL",
|
||||
tools=[
|
||||
"pg_query",
|
||||
"pg_list_schemas",
|
||||
"pg_list_tables",
|
||||
"pg_describe_table",
|
||||
"pg_explain",
|
||||
],
|
||||
required=True,
|
||||
startup_required=False,
|
||||
help_url="https://www.postgresql.org/docs/current/libpq-connect.html",
|
||||
description="PostgreSQL connection string (postgresql://user:pass@host:port/db)",
|
||||
aden_supported=True,
|
||||
aden_provider_name="postgres",
|
||||
direct_api_key_supported=False,
|
||||
api_key_instructions="""Provide a PostgreSQL connection string:
|
||||
|
||||
postgresql://user:password@host:port/database
|
||||
|
||||
Example:
|
||||
postgresql://postgres:secret@localhost:5432/mydb
|
||||
|
||||
The database user should have read-only permissions.""",
|
||||
health_check_endpoint=None,
|
||||
health_check_method=None,
|
||||
credential_id="postgres",
|
||||
credential_key="database_url",
|
||||
),
|
||||
}
|
||||
@@ -85,7 +85,7 @@ class CredentialStoreAdapter:
|
||||
|
||||
# --- Existing CredentialManager API ---
|
||||
|
||||
def get(self, name: str) -> str | None:
|
||||
def get(self, name: str, account: str | None = None) -> str | None:
|
||||
"""
|
||||
Get a credential value by logical name.
|
||||
|
||||
@@ -94,6 +94,10 @@ class CredentialStoreAdapter:
|
||||
|
||||
Args:
|
||||
name: Logical credential name (e.g., "brave_search")
|
||||
account: Optional alias for per-call routing to a specific named local
|
||||
account (e.g. "work"). When provided, looks up the named account
|
||||
from LocalCredentialRegistry before falling through to the store.
|
||||
This mirrors the ``account=`` routing available for Aden credentials.
|
||||
|
||||
Returns:
|
||||
The credential value, or None if not set
|
||||
@@ -104,6 +108,16 @@ class CredentialStoreAdapter:
|
||||
if name not in self._specs:
|
||||
raise KeyError(f"Unknown credential '{name}'. Available: {list(self._specs.keys())}")
|
||||
|
||||
if account is not None:
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
key = LocalCredentialRegistry.default().get_key(name, account)
|
||||
if key is not None:
|
||||
return key
|
||||
except Exception:
|
||||
pass # Fall through to standard store lookup
|
||||
|
||||
return self._store.get(name)
|
||||
|
||||
def get_spec(self, name: str) -> CredentialSpec:
|
||||
@@ -279,19 +293,43 @@ class CredentialStoreAdapter:
|
||||
def get_all_account_info(self) -> list[dict]:
|
||||
"""Collect all accounts across all configured providers.
|
||||
|
||||
Deduplicates by provider name to avoid listing the same provider's
|
||||
accounts twice when multiple specs map to the same provider.
|
||||
Includes both Aden OAuth accounts and named local API key accounts.
|
||||
Deduplicates by (provider, alias) to avoid listing the same account
|
||||
twice when it appears in both stores.
|
||||
"""
|
||||
accounts: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
seen_specs: set[str] = set()
|
||||
seen_accounts: set[tuple[str, str]] = set()
|
||||
|
||||
for name, spec in self._specs.items():
|
||||
provider = spec.credential_id or name
|
||||
if provider in seen or not self.is_available(name):
|
||||
if provider in seen_specs or not self.is_available(name):
|
||||
continue
|
||||
seen.add(provider)
|
||||
accounts.extend(self._store.list_accounts(provider))
|
||||
seen_specs.add(provider)
|
||||
for acct in self._store.list_accounts(provider):
|
||||
key = (acct.get("provider", ""), acct.get("alias", ""))
|
||||
if key not in seen_accounts:
|
||||
seen_accounts.add(key)
|
||||
accounts.append(acct)
|
||||
|
||||
# Include named local API key accounts
|
||||
for acct in self.list_local_accounts():
|
||||
key = (acct.get("provider", ""), acct.get("alias", ""))
|
||||
if key not in seen_accounts:
|
||||
seen_accounts.add(key)
|
||||
accounts.append(acct)
|
||||
|
||||
return accounts
|
||||
|
||||
def get_tool_provider_map(self) -> dict[str, str]:
|
||||
"""Map tool names to provider names for account routing.
|
||||
|
||||
Returns:
|
||||
Dict mapping tool_name -> provider_name
|
||||
(e.g. {"gmail_list_messages": "google", "slack_send_message": "slack"})
|
||||
"""
|
||||
return dict(self._tool_to_cred)
|
||||
|
||||
def get_by_alias(self, provider_name: str, alias: str) -> str | None:
|
||||
"""Resolve a specific account's token by alias."""
|
||||
cred = self._store.get_credential_by_alias(provider_name, alias)
|
||||
@@ -301,6 +339,58 @@ class CredentialStoreAdapter:
|
||||
"""Alias for get_by_alias (backward compat)."""
|
||||
return self.get_by_alias(provider_name, label)
|
||||
|
||||
# --- Local credential registry ---
|
||||
|
||||
def list_local_accounts(self, credential_id: str | None = None) -> list[dict]:
|
||||
"""
|
||||
List named local API key accounts from LocalCredentialRegistry.
|
||||
|
||||
Args:
|
||||
credential_id: If given, filter to this credential type only.
|
||||
|
||||
Returns:
|
||||
List of account dicts (same shape as Aden account dicts, source='local').
|
||||
"""
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
return [info.to_account_dict() for info in registry.list_accounts(credential_id)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def activate_local_account(self, credential_id: str, alias: str) -> bool:
|
||||
"""
|
||||
Inject a named local account's API key into the environment for this session.
|
||||
|
||||
This enables session-level routing: select an account → inject its key as
|
||||
the env var that tools already read. No tool signature changes required.
|
||||
|
||||
Args:
|
||||
credential_id: Logical credential name (e.g. "brave_search").
|
||||
alias: Account alias (e.g. "work").
|
||||
|
||||
Returns:
|
||||
True if the key was found and injected, False otherwise.
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
key = LocalCredentialRegistry.default().get_key(credential_id, alias)
|
||||
if key is None:
|
||||
return False
|
||||
|
||||
spec = self._specs.get(credential_id)
|
||||
if spec is None:
|
||||
return False
|
||||
|
||||
os.environ[spec.env_var] = key
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@property
|
||||
def store(self) -> CredentialStore:
|
||||
"""Access the underlying credential store for advanced operations."""
|
||||
|
||||
@@ -23,7 +23,9 @@ if TYPE_CHECKING:
|
||||
# Import register_tools from each tool module
|
||||
from .account_info_tool import register_tools as register_account_info
|
||||
from .apollo_tool import register_tools as register_apollo
|
||||
from .arxiv_tool import register_tools as register_arxiv
|
||||
from .bigquery_tool import register_tools as register_bigquery
|
||||
from .brevo_tool import register_tools as register_brevo
|
||||
from .calcom_tool import register_tools as register_calcom
|
||||
from .calendar_tool import register_tools as register_calendar
|
||||
from .csv_tool import register_tools as register_csv
|
||||
@@ -59,6 +61,7 @@ from .hubspot_tool import register_tools as register_hubspot
|
||||
from .news_tool import register_tools as register_news
|
||||
from .pdf_read_tool import register_tools as register_pdf_read
|
||||
from .port_scanner import register_tools as register_port_scanner
|
||||
from .postgres_tool import register_tools as register_postgres
|
||||
from .razorpay_tool import register_tools as register_razorpay
|
||||
from .risk_scorer import register_tools as register_risk_scorer
|
||||
from .runtime_logs_tool import register_tools as register_runtime_logs
|
||||
@@ -74,6 +77,9 @@ from .vision_tool import register_tools as register_vision
|
||||
from .web_scrape_tool import register_tools as register_web_scrape
|
||||
from .web_search_tool import register_tools as register_web_search
|
||||
|
||||
# Web and PDF tools
|
||||
from .wikipedia_tool import register_tools as register_wikipedia
|
||||
|
||||
|
||||
def register_all_tools(
|
||||
mcp: FastMCP,
|
||||
@@ -96,6 +102,8 @@ def register_all_tools(
|
||||
register_pdf_read(mcp)
|
||||
register_time(mcp)
|
||||
register_runtime_logs(mcp)
|
||||
register_wikipedia(mcp)
|
||||
register_arxiv(mcp)
|
||||
|
||||
# Tools that need credentials (pass credentials if provided)
|
||||
# web_search supports multiple providers (Google, Brave) with auto-detection
|
||||
@@ -144,6 +152,10 @@ def register_all_tools(
|
||||
register_subdomain_enumerator(mcp)
|
||||
register_risk_scorer(mcp)
|
||||
register_stripe(mcp, credentials=credentials)
|
||||
register_brevo(mcp, credentials=credentials)
|
||||
|
||||
# Postgres tool
|
||||
register_postgres(mcp, credentials=credentials)
|
||||
|
||||
# Return the list of all registered tool names
|
||||
return list(mcp._tool_manager._tools.keys())
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# arXiv Tool
|
||||
|
||||
Search and download scientific papers from arXiv.
|
||||
|
||||
## Description
|
||||
|
||||
Provides two tools for interacting with the arXiv preprint repository:
|
||||
|
||||
- **`search_papers`** — Search for papers by keyword, author, title, or category with flexible sorting
|
||||
- **`download_paper`** — Download a paper as a PDF to a temporary local file by arXiv ID
|
||||
|
||||
## Arguments
|
||||
|
||||
### `search_papers`
|
||||
|
||||
| Argument | Type | Required | Default | Description |
|
||||
| ------------- | --------- | -------- | -------------- | ---------------------------------------------------------------------- |
|
||||
| `query` | str | Yes* | `""` | Search query. Supports field prefixes and boolean operators (see below) |
|
||||
| `id_list` | list[str] | Yes* | `None` | Specific arXiv IDs to retrieve (e.g. `["1706.03762"]`) |
|
||||
| `max_results` | int | No | `10` | Maximum number of results to return (capped at 100) |
|
||||
| `sort_by` | str | No | `"relevance"` | Sort criterion: `"relevance"`, `"lastUpdatedDate"`, `"submittedDate"` |
|
||||
| `sort_order` | str | No | `"descending"` | Sort direction: `"descending"` or `"ascending"` |
|
||||
|
||||
\* At least one of `query` or `id_list` must be provided.
|
||||
|
||||
**Query syntax:**
|
||||
|
||||
- Field prefixes: `ti:` (title), `au:` (author), `abs:` (abstract), `cat:` (category)
|
||||
- Boolean operators: `AND`, `OR`, `ANDNOT` (must be uppercase)
|
||||
- Examples: `"ti:transformer AND au:vaswani"`, `"abs:multi-agent systems"`
|
||||
|
||||
### `download_paper`
|
||||
|
||||
| Argument | Type | Required | Default | Description |
|
||||
| ---------- | ---- | -------- | ------- | ------------------------------------------------------------------------ |
|
||||
| `paper_id` | str | Yes | - | arXiv paper ID, with or without version (e.g. `"2207.13219"`, `"2207.13219v4"`) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
No API credentials required. arXiv is a publicly accessible repository.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```python
|
||||
# Keyword search
|
||||
result = search_papers(query="multi-agent reinforcement learning")
|
||||
|
||||
# Search by title and author
|
||||
result = search_papers(query="ti:attention AND au:vaswani", max_results=5)
|
||||
|
||||
# Search by category, sorted by submission date
|
||||
result = search_papers(
|
||||
query="cat:cs.LG",
|
||||
sort_by="submittedDate",
|
||||
sort_order="descending",
|
||||
max_results=20,
|
||||
)
|
||||
|
||||
# Retrieve specific papers by ID
|
||||
result = search_papers(id_list=["1706.03762", "2005.14165"])
|
||||
|
||||
# Download a paper as a PDF
|
||||
result = download_paper(paper_id="1706.03762")
|
||||
# result["file_path"] → "/tmp/arxiv_papers_<random>/Attention_Is_All_You_Need_1706_03762_.pdf"
|
||||
# Files are stored in a shared managed directory for the lifetime of the server process.
|
||||
# No cleanup needed — the directory is automatically deleted on process exit.
|
||||
```
|
||||
|
||||
## Return Values
|
||||
|
||||
### `search_papers` — success
|
||||
|
||||
Results are truncated to one entry for brevity; `"total"` reflects the actual count returned.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"query": "multi-agent reinforcement learning",
|
||||
"id_list": [],
|
||||
"results": [
|
||||
{
|
||||
"id": "2203.08975v2",
|
||||
"title": "A Survey of Multi-Agent Deep Reinforcement Learning with Communication",
|
||||
"summary": "Communication is an effective mechanism for coordinating the behaviors of multiple agents...",
|
||||
"published": "2022-03-16",
|
||||
"authors": [
|
||||
"Changxi Zhu",
|
||||
"Mehdi Dastani",
|
||||
"Shihan Wang"
|
||||
],
|
||||
"pdf_url": "https://arxiv.org/pdf/2203.08975v2",
|
||||
"categories": [
|
||||
"cs.MA",
|
||||
"cs.LG"
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 10
|
||||
}
|
||||
```
|
||||
|
||||
When using `id_list`, `"query"` is returned as an empty string and `"id_list"` echoes the requested IDs:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"query": "",
|
||||
"id_list": [
|
||||
"1706.03762",
|
||||
"2005.14165"
|
||||
],
|
||||
"results": ["..."],
|
||||
"total": 2
|
||||
}
|
||||
```
|
||||
|
||||
### `download_paper` — success
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file_path": "/tmp/arxiv_papers_<random>/Attention_Is_All_You_Need_1706_03762_.pdf",
|
||||
"paper_id": "1706.03762"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors return `{"success": false, "error": "..."}`.
|
||||
|
||||
### `search_papers`
|
||||
|
||||
| Error message | Cause |
|
||||
|---|---|
|
||||
| `Invalid Request: You must provide either a 'query' or an 'id_list'.` | Both `query` and `id_list` are empty |
|
||||
| `arXiv specific error: <reason>` | `arxiv.ArxivError` raised by the library |
|
||||
| `Network unreachable.` | `ConnectionError` — no internet connectivity |
|
||||
| `arXiv search failed: <reason>` | Any other unexpected exception |
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Invalid Request: You must provide either a 'query' or an 'id_list'."
|
||||
}
|
||||
```
|
||||
|
||||
### `download_paper`
|
||||
|
||||
| Error message | Cause |
|
||||
|---|---|
|
||||
| `No paper found with ID: <id>` | The arXiv ID does not exist |
|
||||
| `PDF URL not available for this paper.` | Paper metadata has no PDF link |
|
||||
| `Failed during download or write: <reason>` | `requests` network error, OS write failure, or arXiv returned an unexpected content type (e.g. HTML error page instead of PDF) |
|
||||
| `arXiv library error: <reason>` | `arxiv.ArxivError` raised during metadata lookup |
|
||||
| `Network error: <reason>` | `ConnectionError` during metadata lookup |
|
||||
| `Unexpected error: <reason>` | Any other unexpected exception (partial file is cleaned up before returning) |
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "No paper found with ID: 0000.00000"
|
||||
}
|
||||
```
|
||||
## Implementation Notes
|
||||
|
||||
**PDF download** uses `requests.get` against `export.arxiv.org` (the designated programmatic subdomain) instead of the deprecated `Result.download_pdf()` helper. The 3-second rate limit only applies to the metadata API — the PDF download itself is a plain HTTPS file transfer and has no such restriction.
|
||||
|
||||
**Temporary storage** — PDFs are written to a module-level `TemporaryDirectory`, cleaned up automatically on process exit via `atexit`. This is intentional: the PDF is a transient bridge between `download_paper` and `pdf_read_tool` — not a deliverable. Using `data_dir` (the framework's session workspace) would pollute `list_data_files` with unreadable binary blobs and accumulate files with no cleanup. `_TEMP_DIR` scopes the file to exactly as long as it's needed.
|
||||
|
||||
**Known limitation:**
|
||||
- **Resumable sessions** — if the process restarts mid-session, `_TEMP_DIR` is wiped and any checkpointed file path becomes invalid. This is unlikely to matter in practice since `pdf_read_tool` should be called immediately after `download_paper` in the same node.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""ArXiv tool package."""
|
||||
|
||||
from .arxiv_tool import register_tools
|
||||
|
||||
__all__ = ["register_tools"]
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
arXiv Tool - Search and download scientific papers.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import arxiv
|
||||
import requests
|
||||
from fastmcp import FastMCP
|
||||
|
||||
_SHARED_ARXIV_CLIENT = arxiv.Client(page_size=100, delay_seconds=3, num_retries=3)
|
||||
|
||||
_TEMP_DIR = tempfile.TemporaryDirectory(prefix="arxiv_papers_")
|
||||
atexit.register(_TEMP_DIR.cleanup)
|
||||
|
||||
|
||||
def register_tools(mcp: FastMCP) -> None:
|
||||
"""Register arXiv tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
def search_papers(
|
||||
query: str = "",
|
||||
id_list: list[str] | None = None,
|
||||
max_results: int = 10,
|
||||
sort_by: Literal["relevance", "lastUpdatedDate", "submittedDate"] = "relevance",
|
||||
sort_order: Literal["descending", "ascending"] = "descending",
|
||||
) -> dict:
|
||||
"""
|
||||
Searches arXiv for scientific papers using keywords or specific IDs.
|
||||
|
||||
CRITICAL: You MUST provide either a `query` OR an `id_list`.
|
||||
|
||||
Args:
|
||||
query (str): The search query (e.g., "multi-agent systems").
|
||||
Default is empty.
|
||||
|
||||
QUERY SYNTAX & PREFIXES:
|
||||
- Use prefixes: 'ti:' (Title), 'au:' (Author),
|
||||
'abs:' (Abstract), 'cat:' (Category).
|
||||
- Boolean: AND, OR, ANDNOT (Must be capitalized).
|
||||
- Example: "ti:transformer AND au:vaswani"
|
||||
|
||||
id_list (list[str] | None): Specific arXiv IDs (e.g., ["1706.03762"]).
|
||||
Use this to retrieve specific known papers.
|
||||
|
||||
max_results (int): Max results to return (default 10).
|
||||
|
||||
sort_by (Literal): The sorting criterion.
|
||||
Options: "relevance", "lastUpdatedDate", "submittedDate".
|
||||
Default: "relevance".
|
||||
|
||||
sort_order (Literal): The order of sorting.
|
||||
Options: "descending", "ascending".
|
||||
Default: "descending".
|
||||
|
||||
Returns:
|
||||
dict: { "success": bool, "data": list[dict], "count": int }
|
||||
"""
|
||||
|
||||
# VALIDATION: Ensure the Agent didn't send an empty request
|
||||
if not query and not id_list:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid Request: You must provide either a 'query' or an 'id_list'.",
|
||||
}
|
||||
|
||||
# Prevent the agent from accidentally requesting too much data
|
||||
max_results = min(max_results, 100)
|
||||
|
||||
# INTERNAL MAPS: Bridge String (Agent) -> Enum Object (Library)
|
||||
sort_criteria_map = {
|
||||
"relevance": arxiv.SortCriterion.Relevance,
|
||||
"lastUpdatedDate": arxiv.SortCriterion.LastUpdatedDate,
|
||||
"submittedDate": arxiv.SortCriterion.SubmittedDate,
|
||||
}
|
||||
sort_order_map = {
|
||||
"descending": arxiv.SortOrder.Descending,
|
||||
"ascending": arxiv.SortOrder.Ascending,
|
||||
}
|
||||
|
||||
try:
|
||||
search = arxiv.Search(
|
||||
query=query,
|
||||
id_list=id_list or [],
|
||||
max_results=max_results,
|
||||
sort_by=sort_criteria_map.get(sort_by, arxiv.SortCriterion.Relevance),
|
||||
sort_order=sort_order_map.get(sort_order, arxiv.SortOrder.Descending),
|
||||
)
|
||||
|
||||
result_object = _SHARED_ARXIV_CLIENT.results(search)
|
||||
results = []
|
||||
|
||||
# EXECUTION & SERIALIZATION
|
||||
for r in result_object:
|
||||
results.append(
|
||||
{
|
||||
"id": r.get_short_id(),
|
||||
"title": r.title,
|
||||
"summary": r.summary.replace("\n", " "),
|
||||
"published": str(r.published.date()),
|
||||
"authors": [a.name for a in r.authors],
|
||||
"pdf_url": r.pdf_url,
|
||||
"categories": r.categories,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"id_list": id_list or [],
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
}
|
||||
except arxiv.ArxivError as e:
|
||||
return {"success": False, "error": f"arXiv specific error: {e}"}
|
||||
|
||||
except ConnectionError:
|
||||
return {"success": False, "error": "Network unreachable."}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"arXiv search failed: {str(e)}"}
|
||||
|
||||
@mcp.tool()
|
||||
def download_paper(paper_id: str) -> dict:
|
||||
"""
|
||||
Downloads a paper from arXiv by its ID and saves it to a managed temporary directory
|
||||
for the lifetime of the server process.
|
||||
|
||||
Args:
|
||||
paper_id (str): The arXiv identifier (e.g., "2207.13219v4").
|
||||
|
||||
Returns:
|
||||
dict: { "success": bool, "file_path": str, "paper_id": str }
|
||||
The file is valid until the server process exits. No cleanup needed.
|
||||
"""
|
||||
local_path = None
|
||||
try:
|
||||
# Find the PDF Link
|
||||
search = arxiv.Search(id_list=[paper_id])
|
||||
results_generator = _SHARED_ARXIV_CLIENT.results(search)
|
||||
paper = next(results_generator, None)
|
||||
|
||||
if not paper:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"No paper found with ID: {paper_id}",
|
||||
}
|
||||
|
||||
pdf_url = paper.pdf_url
|
||||
|
||||
if not pdf_url:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "PDF URL not available for this paper.",
|
||||
}
|
||||
|
||||
parsed_url = urlparse(pdf_url)
|
||||
pdf_url = parsed_url._replace(netloc="export.arxiv.org").geturl()
|
||||
|
||||
# Clean the title to make it a valid filename
|
||||
clean_title = re.sub(r"[^\w\s-]", "", paper.title).strip().replace(" ", "_")
|
||||
clean_id = re.sub(r"[^\w\s-]", "_", paper_id)
|
||||
prefix = f"{clean_title[:50]}_{clean_id}_"
|
||||
|
||||
filename = f"{prefix}.pdf"
|
||||
local_path = os.path.join(_TEMP_DIR.name, filename)
|
||||
|
||||
try:
|
||||
# Start the Stream
|
||||
# stream=True prevents loading the entire file into memory
|
||||
headers = {"User-Agent": "Hive-Agent/1.0 (https://github.com/adenhq/hive)"}
|
||||
|
||||
# No rate limiting needed for PDF download.
|
||||
# The 3-second rule only applies to the metadata API (export.arxiv.org/api/query),
|
||||
# as explicitly stated in the arXiv API User Manual.
|
||||
# This is a plain HTTPS file download (export.arxiv.org/pdf/...), not an API call.
|
||||
# The deprecated arxiv.py helper `Result.download_pdf()` confirms this —
|
||||
# it was just a bare urlretrieve() call,
|
||||
# with zero rate limiting or client involvement,
|
||||
# because Result objects are pure data and hold no reference back to the Client.
|
||||
response = requests.get(pdf_url, stream=True, timeout=60, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "pdf" not in content_type.lower():
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Failed during download or write: Expected PDF content but got "
|
||||
f"'{content_type}'. arXiv may have returned an error page."
|
||||
),
|
||||
}
|
||||
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
except (requests.RequestException, OSError) as e:
|
||||
if os.path.exists(local_path):
|
||||
os.remove(local_path)
|
||||
local_path = None # prevent double-deletion in the outer except
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed during download or write: {str(e)}",
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_path": local_path,
|
||||
"paper_id": paper_id,
|
||||
}
|
||||
|
||||
except arxiv.ArxivError as e:
|
||||
return {"success": False, "error": f"arXiv library error: {str(e)}"}
|
||||
except ConnectionError as e:
|
||||
return {"success": False, "error": f"Network error: {str(e)}"}
|
||||
except Exception as e:
|
||||
if local_path and os.path.exists(local_path):
|
||||
os.remove(local_path)
|
||||
return {"success": False, "error": f"Unexpected error: {str(e)}"}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Brevo (formerly Sendinblue) tool - transactional email, SMS, and contacts."""
|
||||
|
||||
from .brevo_tool import register_tools
|
||||
|
||||
__all__ = ["register_tools"]
|
||||
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
Brevo Tool - Send transactional emails, SMS, and manage contacts via Brevo API.
|
||||
|
||||
Supports:
|
||||
- Transactional email sending
|
||||
- Transactional SMS sending
|
||||
- Contact create/read/update
|
||||
|
||||
API Reference: https://developers.brevo.com/reference
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aden_tools.credentials import CredentialStoreAdapter
|
||||
|
||||
BREVO_API_BASE = "https://api.brevo.com/v3"
|
||||
|
||||
|
||||
class _BrevoClient:
|
||||
"""Internal client wrapping Brevo API v3 calls."""
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
self._api_key = api_key
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"api-key": self._api_key,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
def _handle_response(self, response: httpx.Response) -> dict[str, Any]:
|
||||
"""Handle common HTTP error codes."""
|
||||
if response.status_code == 401:
|
||||
return {"error": "Invalid Brevo API key"}
|
||||
if response.status_code == 400:
|
||||
try:
|
||||
detail = response.json()
|
||||
msg = detail.get("message", response.text)
|
||||
except Exception:
|
||||
msg = response.text
|
||||
return {"error": f"Bad request: {msg}"}
|
||||
if response.status_code == 403:
|
||||
return {"error": "Brevo API key lacks required permissions"}
|
||||
if response.status_code == 404:
|
||||
return {"error": "Resource not found"}
|
||||
if response.status_code == 429:
|
||||
return {"error": "Rate limit exceeded. Try again later."}
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
detail = response.json().get("message", response.text)
|
||||
except Exception:
|
||||
detail = response.text
|
||||
return {"error": f"Brevo API error (HTTP {response.status_code}): {detail}"}
|
||||
# Success (200, 201, 204)
|
||||
if response.status_code == 204:
|
||||
return {"success": True}
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"success": True}
|
||||
|
||||
def send_email(
|
||||
self,
|
||||
to: list[dict[str, str]],
|
||||
subject: str,
|
||||
html_content: str,
|
||||
sender: dict[str, str],
|
||||
text_content: str | None = None,
|
||||
cc: list[dict[str, str]] | None = None,
|
||||
bcc: list[dict[str, str]] | None = None,
|
||||
reply_to: dict[str, str] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a transactional email."""
|
||||
payload: dict[str, Any] = {
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"htmlContent": html_content,
|
||||
"sender": sender,
|
||||
}
|
||||
if text_content:
|
||||
payload["textContent"] = text_content
|
||||
if cc:
|
||||
payload["cc"] = cc
|
||||
if bcc:
|
||||
payload["bcc"] = bcc
|
||||
if reply_to:
|
||||
payload["replyTo"] = reply_to
|
||||
if tags:
|
||||
payload["tags"] = tags
|
||||
|
||||
response = httpx.post(
|
||||
f"{BREVO_API_BASE}/smtp/email",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
def send_sms(
|
||||
self,
|
||||
sender: str,
|
||||
recipient: str,
|
||||
content: str,
|
||||
sms_type: str = "transactional",
|
||||
tag: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a transactional SMS."""
|
||||
payload: dict[str, Any] = {
|
||||
"sender": sender,
|
||||
"recipient": recipient,
|
||||
"content": content,
|
||||
"type": sms_type,
|
||||
}
|
||||
if tag:
|
||||
payload["tag"] = tag
|
||||
|
||||
response = httpx.post(
|
||||
f"{BREVO_API_BASE}/transactionalSMS/send",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
def create_contact(
|
||||
self,
|
||||
email: str | None = None,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
list_ids: list[int] | None = None,
|
||||
update_enabled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new contact."""
|
||||
payload: dict[str, Any] = {}
|
||||
if email:
|
||||
payload["email"] = email
|
||||
if attributes:
|
||||
payload["attributes"] = attributes
|
||||
if list_ids:
|
||||
payload["listIds"] = list_ids
|
||||
if update_enabled:
|
||||
payload["updateEnabled"] = True
|
||||
|
||||
response = httpx.post(
|
||||
f"{BREVO_API_BASE}/contacts",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
def get_contact(self, identifier: str) -> dict[str, Any]:
|
||||
"""Get a contact by email or ID."""
|
||||
response = httpx.get(
|
||||
f"{BREVO_API_BASE}/contacts/{identifier}",
|
||||
headers=self._headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
def update_contact(
|
||||
self,
|
||||
identifier: str,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
list_ids: list[int] | None = None,
|
||||
unlink_list_ids: list[int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update a contact."""
|
||||
payload: dict[str, Any] = {}
|
||||
if attributes:
|
||||
payload["attributes"] = attributes
|
||||
if list_ids:
|
||||
payload["listIds"] = list_ids
|
||||
if unlink_list_ids:
|
||||
payload["unlinkListIds"] = unlink_list_ids
|
||||
|
||||
response = httpx.put(
|
||||
f"{BREVO_API_BASE}/contacts/{identifier}",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
|
||||
def register_tools(
|
||||
mcp: FastMCP,
|
||||
credentials: CredentialStoreAdapter | None = None,
|
||||
) -> None:
|
||||
"""Register Brevo tools with the MCP server."""
|
||||
|
||||
def _get_api_key() -> str | None:
|
||||
"""Get Brevo API key from credential store or environment."""
|
||||
if credentials is not None:
|
||||
key = credentials.get("brevo")
|
||||
if key is not None and not isinstance(key, str):
|
||||
raise TypeError(
|
||||
f"Expected string from credentials.get('brevo'), got {type(key).__name__}"
|
||||
)
|
||||
return key
|
||||
return os.getenv("BREVO_API_KEY")
|
||||
|
||||
def _get_client() -> _BrevoClient | dict[str, str]:
|
||||
"""Get a Brevo client, or return an error dict if no credentials."""
|
||||
api_key = _get_api_key()
|
||||
if not api_key:
|
||||
return {
|
||||
"error": "Brevo API key not configured",
|
||||
"help": (
|
||||
"Set BREVO_API_KEY environment variable or configure via "
|
||||
"credential store. Get your key at https://app.brevo.com/settings/keys/api"
|
||||
),
|
||||
}
|
||||
return _BrevoClient(api_key)
|
||||
|
||||
@mcp.tool()
|
||||
def brevo_send_email(
|
||||
to: list[dict[str, str]],
|
||||
subject: str,
|
||||
html_content: str,
|
||||
sender_email: str,
|
||||
sender_name: str = "",
|
||||
text_content: str = "",
|
||||
cc: list[dict[str, str]] | None = None,
|
||||
bcc: list[dict[str, str]] | None = None,
|
||||
reply_to_email: str = "",
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send a transactional email via Brevo.
|
||||
|
||||
Use this for notifications, alerts, confirmations, or any triggered email.
|
||||
|
||||
Args:
|
||||
to: Recipients list. Each item: {"email": "user@example.com", "name": "User Name"}.
|
||||
Name is optional.
|
||||
subject: Email subject line.
|
||||
html_content: Email body as HTML string.
|
||||
sender_email: Sender email address (must be a verified sender in Brevo).
|
||||
sender_name: Sender display name. Optional.
|
||||
text_content: Plain text alternative body. Optional.
|
||||
cc: CC recipients. Same format as 'to'. Optional.
|
||||
bcc: BCC recipients. Same format as 'to'. Optional.
|
||||
reply_to_email: Reply-to email address. Optional.
|
||||
tags: Tags for categorizing the email. Optional.
|
||||
|
||||
Returns:
|
||||
Dict with messageId on success, or error dict on failure.
|
||||
"""
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
|
||||
if not to:
|
||||
return {"error": "At least one recipient is required"}
|
||||
if not subject:
|
||||
return {"error": "Subject is required"}
|
||||
if not html_content:
|
||||
return {"error": "HTML content is required"}
|
||||
if not sender_email:
|
||||
return {"error": "Sender email is required"}
|
||||
|
||||
sender: dict[str, str] = {"email": sender_email}
|
||||
if sender_name:
|
||||
sender["name"] = sender_name
|
||||
|
||||
reply_to = {"email": reply_to_email} if reply_to_email else None
|
||||
|
||||
try:
|
||||
result = client.send_email(
|
||||
to=to,
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
sender=sender,
|
||||
text_content=text_content if text_content else None,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
reply_to=reply_to,
|
||||
tags=tags,
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": result.get("messageId", ""),
|
||||
"to": [r.get("email") for r in to],
|
||||
"subject": subject,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Brevo request timed out"}
|
||||
except httpx.RequestError as e:
|
||||
return {"error": f"Network error: {e}"}
|
||||
|
||||
@mcp.tool()
|
||||
def brevo_send_sms(
|
||||
sender: str,
|
||||
recipient: str,
|
||||
content: str,
|
||||
sms_type: str = "transactional",
|
||||
tag: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send a transactional SMS via Brevo.
|
||||
|
||||
Use this for SMS notifications, alerts, or verification messages.
|
||||
|
||||
Args:
|
||||
sender: Sender name (max 11 alphanumeric chars) or phone number (max 15 digits).
|
||||
recipient: Recipient phone number with country code (e.g., "33612345678").
|
||||
content: SMS message text. Messages over 160 chars are sent as multiple SMS.
|
||||
sms_type: Either "transactional" or "marketing". Defaults to "transactional".
|
||||
tag: Optional tag for categorizing the SMS.
|
||||
|
||||
Returns:
|
||||
Dict with messageId on success, or error dict on failure.
|
||||
"""
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
|
||||
if not sender:
|
||||
return {"error": "Sender is required"}
|
||||
if not recipient:
|
||||
return {"error": "Recipient phone number is required"}
|
||||
if not content:
|
||||
return {"error": "SMS content is required"}
|
||||
|
||||
try:
|
||||
result = client.send_sms(
|
||||
sender=sender,
|
||||
recipient=recipient,
|
||||
content=content,
|
||||
sms_type=sms_type,
|
||||
tag=tag if tag else None,
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": result.get("messageId", ""),
|
||||
"recipient": recipient,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Brevo request timed out"}
|
||||
except httpx.RequestError as e:
|
||||
return {"error": f"Network error: {e}"}
|
||||
|
||||
@mcp.tool()
|
||||
def brevo_create_contact(
|
||||
email: str,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
list_ids: list[int] | None = None,
|
||||
update_enabled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a contact in Brevo.
|
||||
|
||||
Use this to add new contacts to your Brevo account for email/SMS campaigns.
|
||||
|
||||
Args:
|
||||
email: Contact email address.
|
||||
attributes: Contact attributes in UPPERCASE (e.g., {"FNAME": "John", "LNAME": "Doe"}).
|
||||
Standard attributes: FNAME, LNAME, SMS (phone with country code like +33xxxxxxxxxx).
|
||||
list_ids: List IDs to add the contact to. Optional.
|
||||
update_enabled: If True, updates the contact if it already exists. Defaults to False.
|
||||
|
||||
Returns:
|
||||
Dict with contact id on success, or error dict on failure.
|
||||
"""
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
|
||||
if not email:
|
||||
return {"error": "Email is required"}
|
||||
|
||||
try:
|
||||
result = client.create_contact(
|
||||
email=email,
|
||||
attributes=attributes,
|
||||
list_ids=list_ids,
|
||||
update_enabled=update_enabled,
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
return {
|
||||
"success": True,
|
||||
"id": result.get("id"),
|
||||
"email": email,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Brevo request timed out"}
|
||||
except httpx.RequestError as e:
|
||||
return {"error": f"Network error: {e}"}
|
||||
|
||||
@mcp.tool()
|
||||
def brevo_get_contact(
|
||||
identifier: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get a contact from Brevo by email address or contact ID.
|
||||
|
||||
Args:
|
||||
identifier: Contact email address or numeric contact ID.
|
||||
|
||||
Returns:
|
||||
Dict with contact details (email, attributes, listIds, statistics)
|
||||
or error dict on failure.
|
||||
"""
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
|
||||
if not identifier:
|
||||
return {"error": "Contact identifier (email or ID) is required"}
|
||||
|
||||
try:
|
||||
result = client.get_contact(identifier)
|
||||
if "error" in result:
|
||||
return result
|
||||
return {
|
||||
"success": True,
|
||||
"id": result.get("id"),
|
||||
"email": result.get("email"),
|
||||
"attributes": result.get("attributes", {}),
|
||||
"list_ids": result.get("listIds", []),
|
||||
"email_blacklisted": result.get("emailBlacklisted", False),
|
||||
"sms_blacklisted": result.get("smsBlacklisted", False),
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Brevo request timed out"}
|
||||
except httpx.RequestError as e:
|
||||
return {"error": f"Network error: {e}"}
|
||||
|
||||
@mcp.tool()
|
||||
def brevo_update_contact(
|
||||
identifier: str,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
list_ids: list[int] | None = None,
|
||||
unlink_list_ids: list[int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Update a contact in Brevo.
|
||||
|
||||
Args:
|
||||
identifier: Contact email address or numeric contact ID.
|
||||
attributes: Attributes to update in UPPERCASE (e.g., {"FNAME": "Jane"}).
|
||||
list_ids: List IDs to add the contact to. Optional.
|
||||
unlink_list_ids: List IDs to remove the contact from. Optional.
|
||||
|
||||
Returns:
|
||||
Dict with success status, or error dict on failure.
|
||||
"""
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
|
||||
if not identifier:
|
||||
return {"error": "Contact identifier (email or ID) is required"}
|
||||
|
||||
try:
|
||||
result = client.update_contact(
|
||||
identifier=identifier,
|
||||
attributes=attributes,
|
||||
list_ids=list_ids,
|
||||
unlink_list_ids=unlink_list_ids,
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
return {
|
||||
"success": True,
|
||||
"identifier": identifier,
|
||||
"message": "Contact updated successfully",
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Brevo request timed out"}
|
||||
except httpx.RequestError as e:
|
||||
return {"error": f"Network error: {e}"}
|
||||
@@ -0,0 +1,111 @@
|
||||
# DNS Security Scanner Tool
|
||||
|
||||
Check SPF, DMARC, DKIM, DNSSEC configuration and zone transfer vulnerability.
|
||||
|
||||
## Features
|
||||
|
||||
- **dns_security_scan** - Evaluate email security and DNS infrastructure hardening
|
||||
|
||||
## How It Works
|
||||
|
||||
Performs non-intrusive DNS queries to check:
|
||||
1. SPF record presence and policy strength
|
||||
2. DMARC record presence and enforcement level
|
||||
3. DKIM selectors (probes common selectors)
|
||||
4. DNSSEC enablement
|
||||
5. MX and CAA records
|
||||
6. Zone transfer vulnerability (AXFR)
|
||||
|
||||
**Requires dnspython** - Install with `pip install dnspython`
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Scan
|
||||
```python
|
||||
dns_security_scan(domain="example.com")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### dns_security_scan
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| domain | str | Yes | Domain name to scan (e.g., "example.com") |
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"domain": "example.com",
|
||||
"spf": {
|
||||
"present": true,
|
||||
"record": "v=spf1 include:_spf.google.com -all",
|
||||
"policy": "hardfail",
|
||||
"issues": []
|
||||
},
|
||||
"dmarc": {
|
||||
"present": true,
|
||||
"record": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
|
||||
"policy": "reject",
|
||||
"issues": []
|
||||
},
|
||||
"dkim": {
|
||||
"selectors_found": ["google", "selector1"],
|
||||
"selectors_missing": ["default", "k1", "mail"]
|
||||
},
|
||||
"dnssec": {
|
||||
"enabled": true,
|
||||
"issues": []
|
||||
},
|
||||
"mx_records": ["10 mail.example.com"],
|
||||
"caa_records": ["0 issue \"letsencrypt.org\""],
|
||||
"zone_transfer": {
|
||||
"vulnerable": false
|
||||
},
|
||||
"grade_input": {
|
||||
"spf_present": true,
|
||||
"spf_strict": true,
|
||||
"dmarc_present": true,
|
||||
"dmarc_enforcing": true,
|
||||
"dkim_found": true,
|
||||
"dnssec_enabled": true,
|
||||
"zone_transfer_blocked": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Checks
|
||||
|
||||
| Check | Severity | Description |
|
||||
|-------|----------|-------------|
|
||||
| No SPF record | High | Any server can spoof emails |
|
||||
| SPF softfail (~all) | Medium | Spoofed emails may be delivered |
|
||||
| SPF +all | Critical | Effectively disables SPF |
|
||||
| No DMARC record | High | Email spoofing not blocked |
|
||||
| DMARC p=none | Medium | Monitoring only, no enforcement |
|
||||
| No DKIM | Medium | Emails cannot be cryptographically verified |
|
||||
| DNSSEC disabled | Medium | Vulnerable to DNS spoofing |
|
||||
| Zone transfer allowed | Critical | Full DNS zone can be downloaded |
|
||||
|
||||
## DKIM Selectors Probed
|
||||
|
||||
The tool checks these common DKIM selectors:
|
||||
- `default`, `google`, `selector1`, `selector2`
|
||||
- `k1`, `mail`, `dkim`, `s1`
|
||||
|
||||
## Ethical Use
|
||||
|
||||
⚠️ **Important**: Only scan domains you own or have explicit permission to test.
|
||||
|
||||
- DNS queries are generally non-intrusive
|
||||
- Zone transfer tests may be logged by DNS providers
|
||||
|
||||
## Error Handling
|
||||
```python
|
||||
{"error": "dnspython is not installed. Install it with: pip install dnspython"}
|
||||
{"error": "Could not resolve NS records"}
|
||||
```
|
||||
|
||||
## Integration with Risk Scorer
|
||||
|
||||
The `grade_input` field can be passed to the `risk_score` tool for weighted security grading.
|
||||
@@ -114,10 +114,15 @@ def register_tools(
|
||||
"subject": subject,
|
||||
}
|
||||
|
||||
def _get_credential(provider: Literal["resend", "gmail"]) -> str | None:
|
||||
def _get_credential(
|
||||
provider: Literal["resend", "gmail"],
|
||||
account: str = "",
|
||||
) -> str | None:
|
||||
"""Get the credential for the requested provider."""
|
||||
if provider == "gmail":
|
||||
if credentials is not None:
|
||||
if account:
|
||||
return credentials.get_by_alias("google", account)
|
||||
return credentials.get("google")
|
||||
return os.getenv("GOOGLE_ACCESS_TOKEN")
|
||||
# resend
|
||||
@@ -150,6 +155,7 @@ def register_tools(
|
||||
from_email: str | None = None,
|
||||
cc: str | list[str] | None = None,
|
||||
bcc: str | list[str] | None = None,
|
||||
account: str = "",
|
||||
) -> dict:
|
||||
"""Core email sending logic, callable by other tools."""
|
||||
from_email = _resolve_from_email(from_email)
|
||||
@@ -182,7 +188,7 @@ def register_tools(
|
||||
"help": "Pass from_email or set EMAIL_FROM environment variable",
|
||||
}
|
||||
|
||||
credential = _get_credential(provider)
|
||||
credential = _get_credential(provider, account)
|
||||
if not credential:
|
||||
if provider == "gmail":
|
||||
return {
|
||||
@@ -215,6 +221,7 @@ def register_tools(
|
||||
from_email: str | None = None,
|
||||
cc: str | list[str] | None = None,
|
||||
bcc: str | list[str] | None = None,
|
||||
account: str = "",
|
||||
) -> dict:
|
||||
"""
|
||||
Send an email.
|
||||
@@ -232,12 +239,14 @@ def register_tools(
|
||||
Optional for Gmail (defaults to authenticated user's address).
|
||||
cc: CC recipient(s). Single string or list of strings. Optional.
|
||||
bcc: BCC recipient(s). Single string or list of strings. Optional.
|
||||
account: Account alias for multi-account routing (e.g. "timothy-home").
|
||||
Only used with Gmail provider. Optional.
|
||||
|
||||
Returns:
|
||||
Dict with send result including provider used and message ID,
|
||||
or error dict with "error" and optional "help" keys.
|
||||
"""
|
||||
return _send_email_impl(to, subject, html, provider, from_email, cc, bcc)
|
||||
return _send_email_impl(to, subject, html, provider, from_email, cc, bcc, account)
|
||||
|
||||
def _fetch_original_message(access_token: str, message_id: str) -> dict:
|
||||
"""Fetch the original message to extract threading info."""
|
||||
@@ -278,6 +287,7 @@ def register_tools(
|
||||
html: str,
|
||||
cc: str | list[str] | None = None,
|
||||
bcc: str | list[str] | None = None,
|
||||
account: str = "",
|
||||
) -> dict:
|
||||
"""
|
||||
Reply to a Gmail message, keeping it in the same thread.
|
||||
@@ -291,6 +301,8 @@ def register_tools(
|
||||
html: Reply body as HTML string.
|
||||
cc: CC recipient(s). Single string or list of strings. Optional.
|
||||
bcc: BCC recipient(s). Single string or list of strings. Optional.
|
||||
account: Account alias for multi-account routing (e.g. "timothy-home").
|
||||
Optional.
|
||||
|
||||
Returns:
|
||||
Dict with send result including reply message ID and threadId,
|
||||
@@ -305,7 +317,7 @@ def register_tools(
|
||||
if not html:
|
||||
return {"error": "Reply body (html) is required"}
|
||||
|
||||
credential = _get_credential("gmail")
|
||||
credential = _get_credential("gmail", account)
|
||||
if not credential:
|
||||
return {
|
||||
"error": "Gmail credentials not configured",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# HTTP Headers Scanner Tool
|
||||
|
||||
Check OWASP-recommended security headers and detect information leakage.
|
||||
|
||||
## Features
|
||||
|
||||
- **http_headers_scan** - Evaluate response headers against OWASP Secure Headers Project guidelines
|
||||
|
||||
## How It Works
|
||||
|
||||
Sends a single GET request and analyzes response headers:
|
||||
1. Checks for presence of security headers (HSTS, CSP, X-Frame-Options, etc.)
|
||||
2. Identifies missing headers with remediation guidance
|
||||
3. Detects information-leaking headers (Server, X-Powered-By)
|
||||
|
||||
**No credentials required** - Uses only standard HTTP requests.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Scan
|
||||
```python
|
||||
http_headers_scan(url="https://example.com")
|
||||
```
|
||||
|
||||
### Without Following Redirects
|
||||
```python
|
||||
http_headers_scan(
|
||||
url="https://example.com",
|
||||
follow_redirects=False
|
||||
)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### http_headers_scan
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| url | str | Yes | - | Full URL to scan (auto-prefixes https://) |
|
||||
| follow_redirects | bool | No | True | Whether to follow HTTP redirects |
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/",
|
||||
"status_code": 200,
|
||||
"headers_present": [
|
||||
"Strict-Transport-Security",
|
||||
"X-Content-Type-Options"
|
||||
],
|
||||
"headers_missing": [
|
||||
{
|
||||
"header": "Content-Security-Policy",
|
||||
"severity": "high",
|
||||
"description": "No CSP header. The site is more vulnerable to XSS attacks.",
|
||||
"remediation": "Add a Content-Security-Policy header. Start restrictive: default-src 'self'"
|
||||
}
|
||||
],
|
||||
"leaky_headers": [
|
||||
{
|
||||
"header": "Server",
|
||||
"value": "nginx/1.18.0",
|
||||
"severity": "low",
|
||||
"remediation": "Remove or genericize the Server header to avoid version disclosure."
|
||||
}
|
||||
],
|
||||
"grade_input": {
|
||||
"hsts": true,
|
||||
"csp": false,
|
||||
"x_frame_options": true,
|
||||
"x_content_type_options": true,
|
||||
"referrer_policy": false,
|
||||
"permissions_policy": false,
|
||||
"no_leaky_headers": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Headers Checked
|
||||
|
||||
| Header | Severity | Purpose |
|
||||
|--------|----------|---------|
|
||||
| Strict-Transport-Security | High | Enforces HTTPS connections |
|
||||
| Content-Security-Policy | High | Prevents XSS attacks |
|
||||
| X-Frame-Options | Medium | Prevents clickjacking |
|
||||
| X-Content-Type-Options | Medium | Prevents MIME sniffing |
|
||||
| Referrer-Policy | Low | Controls referrer information |
|
||||
| Permissions-Policy | Low | Restricts browser features |
|
||||
|
||||
## Leaky Headers Detected
|
||||
|
||||
| Header | Risk |
|
||||
|--------|------|
|
||||
| Server | Reveals web server and version |
|
||||
| X-Powered-By | Reveals backend framework |
|
||||
| X-AspNet-Version | Reveals ASP.NET version |
|
||||
| X-Generator | Reveals CMS/platform |
|
||||
|
||||
## Ethical Use
|
||||
|
||||
⚠️ **Important**: Only scan systems you own or have explicit permission to test.
|
||||
|
||||
## Error Handling
|
||||
```python
|
||||
{"error": "Connection failed: [details]"}
|
||||
{"error": "Request to https://example.com timed out"}
|
||||
```
|
||||
|
||||
## Integration with Risk Scorer
|
||||
|
||||
The `grade_input` field can be passed to the `risk_score` tool for weighted security grading.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Port Scanner Tool
|
||||
|
||||
Scan common ports and detect exposed services using non-intrusive TCP connect probes.
|
||||
|
||||
## Features
|
||||
|
||||
- **port_scan** - Scan a host for open ports, grab service banners, and flag risky exposures
|
||||
|
||||
## How It Works
|
||||
|
||||
Performs TCP connect scans using Python's asyncio. The scanner:
|
||||
1. Attempts to establish a TCP connection to each port
|
||||
2. Grabs service banners where available
|
||||
3. Identifies the service type (HTTP, SSH, MySQL, etc.)
|
||||
4. Flags security risks (exposed databases, admin interfaces, legacy protocols)
|
||||
|
||||
**No credentials required** - Uses only standard network connections.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Scan Top 20 Common Ports
|
||||
```python
|
||||
port_scan(
|
||||
hostname="example.com",
|
||||
ports="top20"
|
||||
)
|
||||
```
|
||||
|
||||
### Scan Top 100 Ports
|
||||
```python
|
||||
port_scan(
|
||||
hostname="example.com",
|
||||
ports="top100",
|
||||
timeout=5.0
|
||||
)
|
||||
```
|
||||
|
||||
### Scan Specific Ports
|
||||
```python
|
||||
port_scan(
|
||||
hostname="example.com",
|
||||
ports="80,443,8080,3306,5432"
|
||||
)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### port_scan
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| hostname | str | Yes | - | Domain or IP to scan (e.g., "example.com") |
|
||||
| ports | str | No | "top20" | Ports to scan: "top20", "top100", or comma-separated list |
|
||||
| timeout | float | No | 3.0 | Connection timeout per port in seconds (max 10.0) |
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"hostname": "example.com",
|
||||
"ip": "93.184.216.34",
|
||||
"ports_scanned": 20,
|
||||
"open_ports": [
|
||||
{
|
||||
"port": 80,
|
||||
"service": "HTTP",
|
||||
"banner": "nginx/1.18.0"
|
||||
},
|
||||
{
|
||||
"port": 443,
|
||||
"service": "HTTPS",
|
||||
"banner": ""
|
||||
},
|
||||
{
|
||||
"port": 3306,
|
||||
"service": "MySQL",
|
||||
"banner": "",
|
||||
"severity": "high",
|
||||
"finding": "MySQL port (3306) exposed to internet",
|
||||
"remediation": "Restrict database ports to localhost or VPN only."
|
||||
}
|
||||
],
|
||||
"closed_ports": [21, 22, 23, ...],
|
||||
"grade_input": {
|
||||
"no_database_ports_exposed": false,
|
||||
"no_admin_ports_exposed": true,
|
||||
"no_legacy_ports_exposed": true,
|
||||
"only_web_ports": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Findings
|
||||
|
||||
The scanner flags three categories of risky ports:
|
||||
|
||||
| Category | Ports | Severity |
|
||||
|----------|-------|----------|
|
||||
| Database | 1433 (MSSQL), 3306 (MySQL), 5432 (PostgreSQL), 6379 (Redis), 27017 (MongoDB) | High |
|
||||
| Admin/Remote | 3389 (RDP), 5900 (VNC), 2082-2087 (cPanel) | High |
|
||||
| Legacy | 21 (FTP), 23 (Telnet), 110 (POP3), 143 (IMAP), 445 (SMB) | Medium |
|
||||
|
||||
## Ethical Use
|
||||
|
||||
⚠️ **Important**: Only scan systems you own or have explicit permission to test.
|
||||
|
||||
- This tool performs active network connections
|
||||
- Unauthorized port scanning may violate laws and terms of service
|
||||
- Use responsibly for security assessments of your own infrastructure
|
||||
|
||||
## Error Handling
|
||||
```python
|
||||
{"error": "Could not resolve hostname: invalid.domain"}
|
||||
{"error": "Invalid port list: abc. Use 'top20', 'top100', or '80,443'"}
|
||||
```
|
||||
|
||||
## Integration with Risk Scorer
|
||||
|
||||
The `grade_input` field can be passed to the `risk_score` tool for weighted security grading.
|
||||
@@ -0,0 +1,161 @@
|
||||
# PostgreSQL Tool
|
||||
|
||||
Provide **safe, read-only access** to PostgreSQL databases via MCP (FastMCP).
|
||||
Designed for **introspection, querying, and analysis** without allowing data mutation.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
Set the `DATABASE_URL` environment variable or configure it via the credential store:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL=postgresql://user:password@localhost:5432/mydb
|
||||
```
|
||||
|
||||
|
||||
## All Tools (5 Total)
|
||||
|
||||
### Queries (2)
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `pg_query` | Execute a safe, parameterized read-only SQL query |
|
||||
| `pg_explain` | Explain execution plan for a query |
|
||||
|
||||
|
||||
### Schema Introspection (3)
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `pg_list_schemas` | List all database schemas |
|
||||
| `pg_list_tables` | List tables (optionally filtered by schema) |
|
||||
| `pg_describe_table` | Describe columns of a table |
|
||||
|
||||
|
||||
## Tool Details
|
||||
|
||||
`pg_query`
|
||||
|
||||
Safely execute a parameterized, read-only SQL query.
|
||||
```
|
||||
pg_query(
|
||||
sql="SELECT * FROM users WHERE id = %(id)s",
|
||||
params={"id": 1}
|
||||
)
|
||||
```
|
||||
Returns
|
||||
|
||||
```
|
||||
{
|
||||
"columns": ["id", "name"],
|
||||
"rows": [[123, "Alice"]],
|
||||
"row_count": 1,
|
||||
"max_rows": 1000,
|
||||
"duration_ms": 12,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
`pg_list_schemas`
|
||||
|
||||
List all schemas in the database.
|
||||
|
||||
```
|
||||
pg_list_schemas()
|
||||
```
|
||||
Returns
|
||||
|
||||
```
|
||||
{
|
||||
"result": ["public", "information_schema"],
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
`pg_list_tables`
|
||||
|
||||
List all tables, optionally filtered by schema.
|
||||
```
|
||||
pg_list_tables(schema="public")
|
||||
```
|
||||
Returns
|
||||
```
|
||||
{
|
||||
"result": [
|
||||
{"schema": "public", "table": "users"},
|
||||
{"schema": "public", "table": "orders"}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
`pg_describe_table`
|
||||
|
||||
Describe a table’s columns.
|
||||
|
||||
```
|
||||
pg_describe_table(
|
||||
schema="public",
|
||||
table="users"
|
||||
)
|
||||
```
|
||||
|
||||
Returns
|
||||
```
|
||||
{
|
||||
"result": [
|
||||
{
|
||||
"column": "id",
|
||||
"type": "bigint",
|
||||
"nullable": false,
|
||||
"default": null
|
||||
},
|
||||
{
|
||||
"column": "email",
|
||||
"type": "text",
|
||||
"nullable": false,
|
||||
"default": null
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
`pg_explain`
|
||||
|
||||
Get the execution plan for a query.
|
||||
|
||||
```
|
||||
pg_explain(sql="SELECT * FROM users WHERE id = 1")
|
||||
```
|
||||
|
||||
Returns
|
||||
```
|
||||
{
|
||||
"result": [
|
||||
"Seq Scan on users (cost=0.00..1.05 rows=1 width=32)"
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Limits & Safeguards
|
||||
|
||||
| Guard | Value |
|
||||
|------|-------------|
|
||||
| Max rows returned | `1000` |
|
||||
| Statement timeout | `3000 ms` |
|
||||
| Allowed operations | `SELECT`, `EXPLAIN`, introspection |
|
||||
| SQL logging | Hashed only |
|
||||
|
||||
|
||||
|
||||
## Error Handling
|
||||
|
||||
All tools return MCP-friendly error payloads:
|
||||
|
||||
```
|
||||
{
|
||||
"error": "Query timed out",
|
||||
"success": false
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
from .postgres_tool import register_tools
|
||||
|
||||
__all__ = ["register_tools"]
|
||||
@@ -0,0 +1,534 @@
|
||||
"""
|
||||
PostgreSQL MCP Tool (Read-only)
|
||||
|
||||
Provides safe, read-only access to PostgreSQL databases for AI agents via MCP.
|
||||
|
||||
Security features:
|
||||
- SELECT-only enforcement via SQL guard
|
||||
- Database-level read-only transaction enforcement
|
||||
- Statement timeout
|
||||
- SQL hashing for safe logging (no raw query logs)
|
||||
- CredentialStore integration
|
||||
- Thread-safe connection pooling
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import psycopg2 as psycopg
|
||||
from fastmcp import FastMCP
|
||||
from psycopg2 import pool, sql as pg_sql
|
||||
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
from aden_tools.credentials.store_adapter import CredentialStoreAdapter
|
||||
|
||||
MAX_ROWS = 1000
|
||||
STATEMENT_TIMEOUT_MS = 3000
|
||||
|
||||
MIN_POOL_SIZE = 1
|
||||
MAX_POOL_SIZE = 10
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_connection_pool: pool.ThreadedConnectionPool | None = None
|
||||
_pool_database_url: str | None = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SQL GUARD (First-pass validation)
|
||||
# ============================================================
|
||||
|
||||
FORBIDDEN_PATTERN = re.compile(
|
||||
r"\b(insert|update|delete|merge|upsert|create|alter|drop|truncate|grant|revoke|"
|
||||
r"call|execute|prepare|deallocate|vacuum|analyze)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def validate_sql(sql: str) -> str:
|
||||
"""
|
||||
Validate SQL to ensure:
|
||||
- Single statement
|
||||
- SELECT-only
|
||||
- No mutation keywords
|
||||
|
||||
Note: Database-level read-only enforcement is the final authority.
|
||||
"""
|
||||
sql = sql.strip()
|
||||
|
||||
if sql.endswith(";"):
|
||||
sql = sql[:-1]
|
||||
|
||||
if ";" in sql:
|
||||
raise ValueError("Multiple statements are not allowed")
|
||||
|
||||
if not sql.lower().startswith("select"):
|
||||
raise ValueError("Only SELECT queries are allowed")
|
||||
|
||||
if FORBIDDEN_PATTERN.search(sql):
|
||||
raise ValueError("Forbidden SQL keyword detected")
|
||||
|
||||
return sql
|
||||
|
||||
|
||||
# ============================================================
|
||||
# INTROSPECTION SQL
|
||||
# ============================================================
|
||||
|
||||
LIST_SCHEMAS_SQL = """
|
||||
SELECT schema_name
|
||||
FROM information_schema.schemata
|
||||
ORDER BY schema_name
|
||||
"""
|
||||
|
||||
LIST_TABLES_SQL = """
|
||||
SELECT table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
"""
|
||||
|
||||
DESCRIBE_TABLE_SQL = """
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = %(schema)s
|
||||
AND table_name = %(table)s
|
||||
ORDER BY ordinal_position
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# Pooling
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _get_pool(database_url: str):
|
||||
"""
|
||||
Retrieve a connection pool for the given PostgreSQL database URL.
|
||||
|
||||
This function lazily creates a connection pool when the first request is made.
|
||||
Subsequent requests will reuse the existing connection pool.
|
||||
|
||||
Args:
|
||||
database_url: PostgreSQL database URL
|
||||
|
||||
Returns:
|
||||
A connection pool object
|
||||
"""
|
||||
global _connection_pool, _pool_database_url
|
||||
if _connection_pool is None or _pool_database_url != database_url:
|
||||
if _connection_pool is not None:
|
||||
_connection_pool.closeall()
|
||||
_connection_pool = pool.ThreadedConnectionPool(
|
||||
MIN_POOL_SIZE, MAX_POOL_SIZE, dsn=database_url
|
||||
)
|
||||
_pool_database_url = database_url
|
||||
return _connection_pool
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _get_connection(database_url: str):
|
||||
"""
|
||||
Retrieve a connection from the pool for the given PostgreSQL database URL.
|
||||
|
||||
This function uses a context manager to ensure that the connection is always
|
||||
returned to the pool after use. The connection is also rolled back before
|
||||
being returned to the pool to prevent leaking any active transactions.
|
||||
|
||||
Args:
|
||||
database_url: PostgreSQL database URL
|
||||
|
||||
Yields:
|
||||
A connection object
|
||||
"""
|
||||
pool_instance = _get_pool(database_url)
|
||||
conn = pool_instance.getconn()
|
||||
|
||||
try:
|
||||
# Ensure clean state
|
||||
if conn.closed:
|
||||
conn = pool_instance.getconn()
|
||||
|
||||
conn.rollback() # Clear any aborted transaction
|
||||
conn.set_session(readonly=True)
|
||||
|
||||
yield conn
|
||||
|
||||
finally:
|
||||
try:
|
||||
conn.rollback() # Always rollback before returning to pool
|
||||
except Exception:
|
||||
pass
|
||||
pool_instance.putconn(conn)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _hash_sql(sql: str) -> str:
|
||||
"""
|
||||
Hash a SQL query and return a shortened version of the hash.
|
||||
|
||||
The hash is used to identify cached query results. The shortened hash is
|
||||
returned to prevent the hash from growing too large.
|
||||
|
||||
Args:
|
||||
sql (str): SQL query to hash
|
||||
|
||||
Returns:
|
||||
str: Shortened hash of the SQL query
|
||||
"""
|
||||
return hashlib.sha256(sql.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _error_response(message: str) -> dict:
|
||||
"""
|
||||
Return a standardized error response for the Postgres tool.
|
||||
|
||||
The response will contain an 'error' key with the provided message and a
|
||||
'success' key set to False.
|
||||
|
||||
:param message: The error message to include in the response.
|
||||
:return: A dictionary containing the error response.
|
||||
"""
|
||||
return {"error": message, "success": False}
|
||||
|
||||
|
||||
def _missing_credential_response() -> dict:
|
||||
"""
|
||||
Return a standardized response for a missing required credential.
|
||||
|
||||
The response will contain an error message with the name of the required
|
||||
credential and a help message pointing to the relevant API key instructions.
|
||||
|
||||
:return: A dictionary containing the error message and help instructions.
|
||||
:rtype: dict
|
||||
"""
|
||||
spec = CREDENTIAL_SPECS["postgres"]
|
||||
return {
|
||||
"error": f"Missing required credential: {spec.description}",
|
||||
"help": spec.api_key_instructions,
|
||||
"success": False,
|
||||
}
|
||||
|
||||
|
||||
def _get_database_url(
|
||||
credentials: CredentialStoreAdapter | None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Return a PostgreSQL connection string.
|
||||
|
||||
If `credentials` is provided, it will be queried first.
|
||||
If no connection string is found in `credentials`, the `DATABASE_URL`
|
||||
environment variable will be checked.
|
||||
|
||||
Parameters:
|
||||
credentials (CredentialStoreAdapter | None): Credential store to query.
|
||||
|
||||
Returns:
|
||||
str | None: PostgreSQL connection string or None if not found.
|
||||
"""
|
||||
database_url: str | None = None
|
||||
|
||||
if credentials:
|
||||
database_url = credentials.get("postgres")
|
||||
|
||||
if not database_url:
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
|
||||
return database_url
|
||||
|
||||
|
||||
def register_tools(
|
||||
mcp: FastMCP,
|
||||
credentials: CredentialStoreAdapter | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Register PostgreSQL tools with the MCP server.
|
||||
|
||||
Parameters:
|
||||
mcp (FastMCP): The FastMCP server instance to register tools with.
|
||||
credentials (CredentialStoreAdapter | None): Optional credential store adapter instance.
|
||||
If provided, use the credentials to connect to the PostgreSQL database.
|
||||
If not provided, fall back to using environment variables.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
def pg_query(sql: str, params: dict | None = None) -> dict:
|
||||
"""
|
||||
Execute a read-only SELECT query.
|
||||
|
||||
Parameters:
|
||||
sql (str): SQL SELECT query
|
||||
params (dict, optional): Parameterized query values
|
||||
|
||||
Returns:
|
||||
dict:
|
||||
columns (list[str])
|
||||
rows (list[list[Any]])
|
||||
row_count (int)
|
||||
duration_ms (int)
|
||||
success (bool)
|
||||
"""
|
||||
database_url = _get_database_url(credentials)
|
||||
if not database_url:
|
||||
return _missing_credential_response()
|
||||
|
||||
start = time.monotonic()
|
||||
sql_hash = _hash_sql(sql)
|
||||
|
||||
try:
|
||||
sql = validate_sql(sql)
|
||||
params = params or {}
|
||||
|
||||
with _get_connection(database_url) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET statement_timeout TO %s",
|
||||
(STATEMENT_TIMEOUT_MS,),
|
||||
)
|
||||
cur.execute(sql, params)
|
||||
|
||||
columns = [d.name for d in cur.description]
|
||||
rows = cur.fetchmany(MAX_ROWS)
|
||||
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
|
||||
logger.info(
|
||||
"postgres.query.success",
|
||||
extra={
|
||||
"sql_hash": sql_hash,
|
||||
"row_count": len(rows),
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
"row_count": len(rows),
|
||||
"max_rows": MAX_ROWS,
|
||||
"duration_ms": duration_ms,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"postgres.query.validation_error",
|
||||
extra={"sql_hash": sql_hash, "error": str(e)},
|
||||
)
|
||||
return _error_response(str(e))
|
||||
|
||||
except psycopg.errors.QueryCanceled:
|
||||
logger.warning(
|
||||
"postgres.query.timeout",
|
||||
extra={"sql_hash": sql_hash},
|
||||
)
|
||||
return _error_response("Query timed out")
|
||||
|
||||
except psycopg.Error as e:
|
||||
logger.error(
|
||||
"postgres.query.db_error",
|
||||
extra={"sql_hash": sql_hash, "error": str(e)},
|
||||
)
|
||||
return _error_response("Database error while executing query")
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"postgres.query.unexpected_error",
|
||||
extra={"sql_hash": sql_hash},
|
||||
)
|
||||
return _error_response("Unexpected error while executing query")
|
||||
|
||||
@mcp.tool()
|
||||
def pg_list_schemas() -> dict:
|
||||
"""
|
||||
List all schemas in the PostgreSQL database.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the list of schemas.
|
||||
- result (list): A list of schema names.
|
||||
- success (bool): Whether the operation succeeded.
|
||||
|
||||
Raises:
|
||||
dict: An error dictionary containing information about the failure.
|
||||
- error (str): A description of the error.
|
||||
- help (str): Optional help text.
|
||||
"""
|
||||
database_url = _get_database_url(credentials)
|
||||
if not database_url:
|
||||
return _missing_credential_response()
|
||||
|
||||
try:
|
||||
with _get_connection(database_url) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(LIST_SCHEMAS_SQL)
|
||||
result = [r[0] for r in cur.fetchall()]
|
||||
|
||||
return {"result": result, "success": True}
|
||||
|
||||
except psycopg.Error:
|
||||
return _error_response("Failed to list schemas")
|
||||
|
||||
@mcp.tool()
|
||||
def pg_list_tables(schema: str | None = None) -> dict:
|
||||
"""
|
||||
List all tables in the database.
|
||||
|
||||
Args:
|
||||
schema (str | None): The schema to filter tables by. If None, all tables are returned.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the list of tables.
|
||||
- result (list): A list of dictionaries, each containing:
|
||||
- schema (str): The schema of the table.
|
||||
- table (str): The name of the table.
|
||||
- success (bool): Whether the operation succeeded.
|
||||
"""
|
||||
database_url = _get_database_url(credentials)
|
||||
if not database_url:
|
||||
return _missing_credential_response()
|
||||
|
||||
try:
|
||||
params: dict[str, Any] = {}
|
||||
sql = LIST_TABLES_SQL
|
||||
|
||||
if schema:
|
||||
sql += " AND table_schema = %(schema)s"
|
||||
params["schema"] = schema
|
||||
|
||||
with _get_connection(database_url) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
result = [{"schema": r[0], "table": r[1]} for r in rows if len(r) >= 2]
|
||||
|
||||
return {"result": result, "success": True}
|
||||
|
||||
except psycopg.Error:
|
||||
return _error_response("Failed to list tables")
|
||||
|
||||
@mcp.tool()
|
||||
def pg_describe_table(schema: str, table: str) -> dict:
|
||||
"""
|
||||
Describe a PostgreSQL table.
|
||||
|
||||
Args:
|
||||
schema (str): The schema of the table.
|
||||
table (str): The name of the table.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the description of the table.
|
||||
- result (list): A list of column descriptions, each containing:
|
||||
- column (str): The column name.
|
||||
- type (str): The column type.
|
||||
- nullable (bool): Whether the column is nullable.
|
||||
- default (str): The column's default value.
|
||||
- success (bool): Whether the operation succeeded.
|
||||
|
||||
Raises:
|
||||
dict: An error dictionary containing information about the failure.
|
||||
- error (str): A description of the error.
|
||||
- help (str): Optional help text.
|
||||
"""
|
||||
database_url = _get_database_url(credentials)
|
||||
if not database_url:
|
||||
return _missing_credential_response()
|
||||
|
||||
try:
|
||||
with _get_connection(database_url) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
DESCRIBE_TABLE_SQL,
|
||||
{"schema": schema, "table": table},
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
result = [
|
||||
{
|
||||
"column": r[0],
|
||||
"type": r[1],
|
||||
"nullable": r[2],
|
||||
"default": r[3],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return {"result": result, "success": True}
|
||||
|
||||
except psycopg.Error:
|
||||
return _error_response("Failed to describe table")
|
||||
|
||||
@mcp.tool()
|
||||
def pg_explain(sql: str) -> dict:
|
||||
"""
|
||||
Explain the execution plan of a query.
|
||||
|
||||
Args:
|
||||
sql (str): SQL query to explain
|
||||
|
||||
Returns:
|
||||
dict: Execution plan as a list of strings
|
||||
"""
|
||||
database_url = _get_database_url(credentials)
|
||||
if not database_url:
|
||||
return _missing_credential_response()
|
||||
|
||||
sql_hash = _hash_sql(sql)
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
sql = validate_sql(sql)
|
||||
|
||||
with _get_connection(database_url) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(pg_sql.SQL("EXPLAIN {}").format(pg_sql.SQL(sql)))
|
||||
plan = [r[0] for r in cur.fetchall()]
|
||||
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
|
||||
logger.info(
|
||||
"postgres.explain.success",
|
||||
extra={
|
||||
"sql_hash": sql_hash,
|
||||
"duration_ms": duration_ms,
|
||||
"plan_lines": len(plan),
|
||||
},
|
||||
)
|
||||
|
||||
return {"result": plan, "success": True}
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"postgres.explain.validation_error",
|
||||
extra={
|
||||
"sql_hash": sql_hash,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return _error_response(str(e))
|
||||
|
||||
except psycopg.Error as e:
|
||||
logger.error(
|
||||
"postgres.explain.db_error",
|
||||
extra={
|
||||
"sql_hash": sql_hash,
|
||||
"pgcode": getattr(e, "pgcode", None),
|
||||
},
|
||||
)
|
||||
return _error_response("Failed to explain query")
|
||||
@@ -0,0 +1,156 @@
|
||||
# Risk Scorer Tool
|
||||
|
||||
Calculate weighted letter-grade risk scores from security scan results.
|
||||
|
||||
## Features
|
||||
|
||||
- **risk_score** - Aggregate findings from all scanning tools into A-F grades per category and overall
|
||||
|
||||
## How It Works
|
||||
|
||||
Consumes `grade_input` from the 6 scanning tools and produces:
|
||||
1. Per-category scores (0-100) and letter grades (A-F)
|
||||
2. Weighted overall score based on category importance
|
||||
3. Top 10 risks sorted by severity
|
||||
4. Handles missing scans gracefully (redistributes weight)
|
||||
|
||||
**Pure Python** - No external dependencies.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Score All Scan Results
|
||||
```python
|
||||
risk_score(
|
||||
ssl_results='{"grade_input": {"tls_version_ok": true, ...}}',
|
||||
headers_results='{"grade_input": {"hsts": true, ...}}',
|
||||
dns_results='{"grade_input": {"spf_present": true, ...}}',
|
||||
ports_results='{"grade_input": {"no_database_ports_exposed": true, ...}}',
|
||||
tech_results='{"grade_input": {"server_version_hidden": false, ...}}',
|
||||
subdomain_results='{"grade_input": {"no_dev_staging_exposed": true, ...}}'
|
||||
)
|
||||
```
|
||||
|
||||
### Partial Scan (Some Categories Skipped)
|
||||
```python
|
||||
# Only SSL and headers scanned
|
||||
risk_score(
|
||||
ssl_results='{"grade_input": {...}}',
|
||||
headers_results='{"grade_input": {...}}'
|
||||
)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### risk_score
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| ssl_results | str | No | JSON string from ssl_tls_scan |
|
||||
| headers_results | str | No | JSON string from http_headers_scan |
|
||||
| dns_results | str | No | JSON string from dns_security_scan |
|
||||
| ports_results | str | No | JSON string from port_scan |
|
||||
| tech_results | str | No | JSON string from tech_stack_detect |
|
||||
| subdomain_results | str | No | JSON string from subdomain_enumerate |
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"overall_score": 72,
|
||||
"overall_grade": "C",
|
||||
"categories": {
|
||||
"ssl_tls": {
|
||||
"score": 85,
|
||||
"grade": "B",
|
||||
"weight": 0.20,
|
||||
"findings_count": 1,
|
||||
"skipped": false
|
||||
},
|
||||
"http_headers": {
|
||||
"score": 60,
|
||||
"grade": "C",
|
||||
"weight": 0.20,
|
||||
"findings_count": 3,
|
||||
"skipped": false
|
||||
},
|
||||
"dns_security": {
|
||||
"score": null,
|
||||
"grade": "N/A",
|
||||
"weight": 0.15,
|
||||
"findings_count": 0,
|
||||
"skipped": true
|
||||
}
|
||||
},
|
||||
"top_risks": [
|
||||
"Missing Content-Security-Policy header (Http Headers: C)",
|
||||
"No DMARC record found (Dns Security: D)",
|
||||
"Database port(s) exposed to internet (Network Exposure: D)"
|
||||
],
|
||||
"grade_scale": {
|
||||
"A": "90-100: Excellent security posture",
|
||||
"B": "75-89: Good, minor improvements needed",
|
||||
"C": "60-74: Fair, notable security gaps",
|
||||
"D": "40-59: Poor, significant vulnerabilities",
|
||||
"F": "0-39: Critical, immediate action required"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Grade Scale
|
||||
|
||||
| Grade | Score | Meaning |
|
||||
|-------|-------|---------|
|
||||
| A | 90-100 | Excellent security posture |
|
||||
| B | 75-89 | Good, minor improvements needed |
|
||||
| C | 60-74 | Fair, notable security gaps |
|
||||
| D | 40-59 | Poor, significant vulnerabilities |
|
||||
| F | 0-39 | Critical, immediate action required |
|
||||
|
||||
## Category Weights
|
||||
|
||||
| Category | Weight | Source Tool |
|
||||
|----------|--------|-------------|
|
||||
| SSL/TLS | 20% | ssl_tls_scan |
|
||||
| HTTP Headers | 20% | http_headers_scan |
|
||||
| DNS Security | 15% | dns_security_scan |
|
||||
| Network Exposure | 15% | port_scan |
|
||||
| Technology | 15% | tech_stack_detect |
|
||||
| Attack Surface | 15% | subdomain_enumerate |
|
||||
|
||||
## Scoring Logic
|
||||
|
||||
Each category has specific checks worth points:
|
||||
- Passing a check earns full points
|
||||
- Failing a check earns zero points and adds a finding
|
||||
- Missing data (scan not run) earns half credit
|
||||
|
||||
The overall score is a weighted average of category scores, normalized if some categories were skipped.
|
||||
|
||||
## Workflow Example
|
||||
```python
|
||||
# 1. Run all scans
|
||||
ssl = ssl_tls_scan("example.com")
|
||||
headers = http_headers_scan("https://example.com")
|
||||
dns = dns_security_scan("example.com")
|
||||
ports = port_scan("example.com")
|
||||
tech = tech_stack_detect("https://example.com")
|
||||
subs = subdomain_enumerate("example.com")
|
||||
|
||||
# 2. Calculate risk score
|
||||
import json
|
||||
score = risk_score(
|
||||
ssl_results=json.dumps(ssl),
|
||||
headers_results=json.dumps(headers),
|
||||
dns_results=json.dumps(dns),
|
||||
ports_results=json.dumps(ports),
|
||||
tech_results=json.dumps(tech),
|
||||
subdomain_results=json.dumps(subs)
|
||||
)
|
||||
|
||||
# 3. Review results
|
||||
print(f"Overall Grade: {score['overall_grade']}")
|
||||
print(f"Top Risks: {score['top_risks']}")
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Invalid JSON inputs are treated as skipped categories (grade = N/A).
|
||||
@@ -0,0 +1,96 @@
|
||||
# SSL/TLS Scanner Tool
|
||||
|
||||
Analyze SSL/TLS configuration and certificate security for any HTTPS endpoint.
|
||||
|
||||
## Features
|
||||
|
||||
- **ssl_tls_scan** - Check TLS version, cipher suite, certificate validity, and common misconfigurations
|
||||
|
||||
## How It Works
|
||||
|
||||
Performs non-intrusive TLS handshake analysis using Python's ssl module:
|
||||
1. Establishes a TLS connection to the target
|
||||
2. Extracts certificate details (issuer, expiry, SANs)
|
||||
3. Checks TLS version and cipher strength
|
||||
4. Identifies security issues and misconfigurations
|
||||
|
||||
**No credentials required** - Uses only Python stdlib (ssl + socket).
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Scan
|
||||
```python
|
||||
ssl_tls_scan(hostname="example.com")
|
||||
```
|
||||
|
||||
### Scan Non-Standard Port
|
||||
```python
|
||||
ssl_tls_scan(hostname="example.com", port=8443)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### ssl_tls_scan
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| hostname | str | Yes | - | Domain name to scan (e.g., "example.com") |
|
||||
| port | int | No | 443 | Port to connect to |
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"hostname": "example.com",
|
||||
"port": 443,
|
||||
"tls_version": "TLSv1.3",
|
||||
"cipher": "TLS_AES_256_GCM_SHA384",
|
||||
"cipher_bits": 256,
|
||||
"certificate": {
|
||||
"subject": "CN=example.com",
|
||||
"issuer": "CN=R3, O=Let's Encrypt, C=US",
|
||||
"not_before": "2024-01-01T00:00:00+00:00",
|
||||
"not_after": "2024-04-01T00:00:00+00:00",
|
||||
"days_until_expiry": 45,
|
||||
"san": ["example.com", "www.example.com"],
|
||||
"self_signed": false,
|
||||
"sha256_fingerprint": "abc123..."
|
||||
},
|
||||
"issues": [],
|
||||
"grade_input": {
|
||||
"tls_version_ok": true,
|
||||
"cert_valid": true,
|
||||
"cert_expiring_soon": false,
|
||||
"strong_cipher": true,
|
||||
"self_signed": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Checks
|
||||
|
||||
| Check | Severity | Description |
|
||||
|-------|----------|-------------|
|
||||
| Insecure TLS version | High | TLS 1.0, 1.1, SSLv2, SSLv3 are vulnerable |
|
||||
| Weak cipher suite | High | RC4, DES, 3DES, MD5, NULL, EXPORT ciphers |
|
||||
| Certificate expired | Critical | SSL certificate has expired |
|
||||
| Certificate expiring soon | Medium | Expires within 30 days |
|
||||
| Self-signed certificate | High | Not trusted by browsers |
|
||||
| Verification failed | Critical | Certificate chain validation failed |
|
||||
|
||||
## Ethical Use
|
||||
|
||||
⚠️ **Important**: Only scan systems you own or have explicit permission to test.
|
||||
|
||||
- This tool performs active TLS connections
|
||||
- Scanning third-party sites without permission may violate terms of service
|
||||
|
||||
## Error Handling
|
||||
```python
|
||||
{"error": "Connection to example.com:443 timed out"}
|
||||
{"error": "Connection to example.com:443 refused. Port may be closed."}
|
||||
{"error": "Connection failed: [SSL error details]"}
|
||||
```
|
||||
|
||||
## Integration with Risk Scorer
|
||||
|
||||
The `grade_input` field can be passed to the `risk_score` tool for weighted security grading.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Subdomain Enumerator Tool
|
||||
|
||||
Discover subdomains via Certificate Transparency (CT) logs using passive OSINT.
|
||||
|
||||
## Features
|
||||
|
||||
- **subdomain_enumerate** - Find subdomains from public CT log data and flag sensitive environments
|
||||
|
||||
## How It Works
|
||||
|
||||
Queries crt.sh (Certificate Transparency log aggregator) to discover subdomains:
|
||||
1. Fetches all certificates issued for the domain
|
||||
2. Extracts subdomain names from certificate SANs
|
||||
3. Identifies potentially sensitive subdomains (staging, dev, admin, etc.)
|
||||
|
||||
**Fully passive** - No active DNS enumeration or brute-forcing.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Enumeration
|
||||
```python
|
||||
subdomain_enumerate(domain="example.com")
|
||||
```
|
||||
|
||||
### Limit Results
|
||||
```python
|
||||
subdomain_enumerate(
|
||||
domain="example.com",
|
||||
max_results=100
|
||||
)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### subdomain_enumerate
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| domain | str | Yes | - | Base domain to enumerate |
|
||||
| max_results | int | No | 50 | Maximum subdomains to return (max 200) |
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"domain": "example.com",
|
||||
"source": "crt.sh (Certificate Transparency)",
|
||||
"total_found": 25,
|
||||
"subdomains": [
|
||||
"www.example.com",
|
||||
"api.example.com",
|
||||
"staging.example.com",
|
||||
"mail.example.com"
|
||||
],
|
||||
"interesting": [
|
||||
{
|
||||
"subdomain": "staging.example.com",
|
||||
"reason": "Staging environment exposed publicly",
|
||||
"severity": "medium",
|
||||
"remediation": "Restrict staging to VPN or internal network access."
|
||||
},
|
||||
{
|
||||
"subdomain": "admin.example.com",
|
||||
"reason": "Admin panel subdomain exposed publicly",
|
||||
"severity": "high",
|
||||
"remediation": "Restrict admin panels to VPN or trusted IP ranges."
|
||||
}
|
||||
],
|
||||
"grade_input": {
|
||||
"no_dev_staging_exposed": false,
|
||||
"no_admin_exposed": false,
|
||||
"reasonable_surface_area": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sensitive Subdomain Detection
|
||||
|
||||
| Keyword | Severity | Risk |
|
||||
|---------|----------|------|
|
||||
| admin | High | Admin panel exposed |
|
||||
| backup | High | Backup infrastructure exposed |
|
||||
| debug | High | Debug endpoints exposed |
|
||||
| staging | Medium | Staging environment exposed |
|
||||
| dev | Medium | Development environment exposed |
|
||||
| test | Medium | Test environment exposed |
|
||||
| internal | Medium | Internal systems in CT logs |
|
||||
| ftp | Medium | Legacy FTP service |
|
||||
| vpn | Low | VPN endpoint discoverable |
|
||||
| api | Low | API attack surface |
|
||||
| mail | Info | Mail server (check SPF/DKIM/DMARC) |
|
||||
|
||||
## Ethical Use
|
||||
|
||||
⚠️ **Important**:
|
||||
|
||||
- This tool uses only public Certificate Transparency data
|
||||
- CT logs are public by design (browser transparency requirement)
|
||||
- Still, only enumerate domains you have authorization to assess
|
||||
- Discovery of subdomains does not grant permission to test them
|
||||
|
||||
## Error Handling
|
||||
```python
|
||||
{"error": "crt.sh returned HTTP 503", "domain": "example.com"}
|
||||
{"error": "crt.sh request timed out (try again later)", "domain": "example.com"}
|
||||
{"error": "CT log query failed: [details]", "domain": "example.com"}
|
||||
```
|
||||
|
||||
## Integration with Risk Scorer
|
||||
|
||||
The `grade_input` field can be passed to the `risk_score` tool for weighted security grading.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Tech Stack Detector Tool
|
||||
|
||||
Fingerprint web technologies through passive HTTP analysis.
|
||||
|
||||
## Features
|
||||
|
||||
- **tech_stack_detect** - Identify web server, framework, CMS, JavaScript libraries, CDN, and security configuration
|
||||
|
||||
## How It Works
|
||||
|
||||
Performs non-intrusive HTTP requests to identify technologies:
|
||||
1. Analyzes response headers (Server, X-Powered-By)
|
||||
2. Parses HTML for JS libraries, frameworks, and CMS signatures
|
||||
3. Inspects cookies for backend technology hints
|
||||
4. Probes common paths (wp-admin, security.txt, etc.)
|
||||
5. Detects CDN and analytics services
|
||||
|
||||
**No credentials required** - Uses only standard HTTP requests.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Detection
|
||||
```python
|
||||
tech_stack_detect(url="https://example.com")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### tech_stack_detect
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| url | str | Yes | URL to analyze (auto-prefixes https://) |
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/",
|
||||
"server": {
|
||||
"name": "nginx",
|
||||
"version": "1.18.0",
|
||||
"raw": "nginx/1.18.0"
|
||||
},
|
||||
"framework": "Express",
|
||||
"language": "Node.js",
|
||||
"cms": "WordPress",
|
||||
"javascript_libraries": ["React", "jQuery 3.6.0"],
|
||||
"cdn": "Cloudflare",
|
||||
"analytics": ["Google Analytics"],
|
||||
"security_txt": true,
|
||||
"robots_txt": true,
|
||||
"interesting_paths": ["/api/", "/admin/"],
|
||||
"cookies": [
|
||||
{
|
||||
"name": "session",
|
||||
"secure": true,
|
||||
"httponly": true,
|
||||
"samesite": "Strict"
|
||||
}
|
||||
],
|
||||
"grade_input": {
|
||||
"server_version_hidden": false,
|
||||
"framework_version_hidden": true,
|
||||
"security_txt_present": true,
|
||||
"cookies_secure": true,
|
||||
"cookies_httponly": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Technologies Detected
|
||||
|
||||
### Web Servers
|
||||
nginx, Apache, IIS, LiteSpeed, etc.
|
||||
|
||||
### Frameworks & Languages
|
||||
- **PHP**: Laravel, WordPress, Drupal
|
||||
- **Python**: Django, Flask
|
||||
- **JavaScript**: Express, Next.js, Nuxt.js
|
||||
- **Ruby**: Rails
|
||||
- **Java**: Spring
|
||||
- **.NET**: ASP.NET
|
||||
|
||||
### JavaScript Libraries
|
||||
React, Angular, Vue.js, jQuery, Bootstrap, Tailwind CSS, Svelte
|
||||
|
||||
### CMS Platforms
|
||||
WordPress, Drupal, Joomla, Shopify, Squarespace, Wix, Ghost
|
||||
|
||||
### CDN Providers
|
||||
Cloudflare, AWS CloudFront, Fastly, Akamai, Vercel, Netlify
|
||||
|
||||
### Analytics
|
||||
Google Analytics, Facebook Pixel, Hotjar, Mixpanel, Segment
|
||||
|
||||
## Security Checks
|
||||
|
||||
| Check | Risk |
|
||||
|-------|------|
|
||||
| Server version disclosed | Enables targeted exploits |
|
||||
| Framework version disclosed | Enables targeted exploits |
|
||||
| No security.txt | No vulnerability reporting channel |
|
||||
| Cookies missing Secure flag | Transmitted over HTTP |
|
||||
| Cookies missing HttpOnly flag | Accessible to JavaScript (XSS risk) |
|
||||
|
||||
## Ethical Use
|
||||
|
||||
⚠️ **Important**: Only scan systems you own or have explicit permission to test.
|
||||
|
||||
- This tool sends multiple HTTP requests
|
||||
- Path probing may be logged by the target
|
||||
|
||||
## Error Handling
|
||||
```python
|
||||
{"error": "Connection failed: [details]"}
|
||||
{"error": "Request to https://example.com timed out"}
|
||||
{"error": "Detection failed: [details]"}
|
||||
```
|
||||
|
||||
## Integration with Risk Scorer
|
||||
|
||||
The `grade_input` field can be passed to the `risk_score` tool for weighted security grading.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Wikipedia Search Tool
|
||||
|
||||
This tool allows agents to search Wikipedia and retrieve article summaries without needing an external API key.
|
||||
|
||||
## Features
|
||||
|
||||
- **Search**: Find relevant Wikipedia articles by query.
|
||||
- **Summaries**: Get concise descriptions and excerpts for search results.
|
||||
- **Multilingual**: Supports searching in different languages (default: English).
|
||||
- **No API Key**: Uses the public Wikipedia REST API.
|
||||
|
||||
## Usage
|
||||
|
||||
### As an MCP Tool
|
||||
|
||||
```python
|
||||
result = await call_tool(
|
||||
"search_wikipedia",
|
||||
arguments={
|
||||
"query": "Artificial Intelligence",
|
||||
"num_results": 3,
|
||||
"lang": "en"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `query` | `str` | Required | The search term to look for. |
|
||||
| `num_results` | `int` | `3` | Number of results to return (max 10). |
|
||||
| `lang` | `str` | `"en"` | Wikipedia language code (e.g., "en", "es", "fr"). |
|
||||
|
||||
## Response Format
|
||||
|
||||
The tool returns a dictionary with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "Artificial Intelligence",
|
||||
"lang": "en",
|
||||
"count": 3,
|
||||
"results": [
|
||||
{
|
||||
"title": "Artificial intelligence",
|
||||
"url": "https://en.wikipedia.org/wiki/Artificial_intelligence",
|
||||
"description": "Intelligence of machines",
|
||||
"snippet": "Artificial intelligence (AI), in its broadest sense, is intelligence exhibited by machines, particularly the computer systems..."
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
from .wikipedia_tool import register_tools
|
||||
|
||||
__all__ = ["register_tools"]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Wikipedia Search Tool - Search and retrieve summaries from Wikipedia.
|
||||
|
||||
Uses the Wikipedia Public API (REST) to find relevant articles and get their intros.
|
||||
No external 'wikipedia' library required, uses standard `httpx`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
def register_tools(mcp: FastMCP) -> None:
|
||||
"""Register wikipedia tool with the MCP server."""
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
"""Remove HTML tags from a string."""
|
||||
if not text:
|
||||
return ""
|
||||
return re.sub(r"<[^>]+>", "", text)
|
||||
|
||||
@mcp.tool()
|
||||
def search_wikipedia(query: str, lang: str = "en", num_results: int = 3) -> dict:
|
||||
"""
|
||||
Search Wikipedia for a given query and return summaries of top matching articles.
|
||||
|
||||
Args:
|
||||
query: The search term (e.g. "Artificial Intelligence")
|
||||
lang: Language code (default: "en")
|
||||
num_results: Number of pages to retrieve (default: 3, max: 10)
|
||||
|
||||
Returns:
|
||||
Dict containing query metadata and list of results (title, summary, url).
|
||||
"""
|
||||
if not query:
|
||||
return {"error": "Query cannot be empty"}
|
||||
|
||||
num_results = max(1, min(num_results, 10))
|
||||
base_url = f"https://{lang}.wikipedia.org/w/rest.php/v1/search/page"
|
||||
|
||||
try:
|
||||
# 1. Search for pages
|
||||
response = httpx.get(
|
||||
base_url,
|
||||
params={"q": query, "limit": num_results},
|
||||
timeout=10.0,
|
||||
headers={"User-Agent": "AdenAgentFramework/1.0 (https://adenhq.com)"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return {"error": f"Wikipedia API error: {response.status_code}", "query": query}
|
||||
|
||||
data = response.json()
|
||||
pages = data.get("pages", [])
|
||||
|
||||
results = []
|
||||
for page in pages:
|
||||
# Basic info
|
||||
title = page.get("title", "")
|
||||
key = page.get("key", "")
|
||||
|
||||
# Use description or excerpt for summary
|
||||
description = page.get("description") or "No description available."
|
||||
excerpt = page.get("excerpt") or ""
|
||||
|
||||
# Clean up HTML from excerpt (e.g. <span class="searchmatch">)
|
||||
snippet = _strip_html(excerpt)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"url": f"https://{lang}.wikipedia.org/wiki/{key}",
|
||||
"description": description,
|
||||
"snippet": snippet,
|
||||
}
|
||||
)
|
||||
|
||||
return {"query": query, "lang": lang, "count": len(results), "results": results}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Request timed out"}
|
||||
except httpx.RequestError as e:
|
||||
return {"error": f"Network error: {str(e)}"}
|
||||
except Exception as e:
|
||||
return {"error": f"Search failed: {str(e)}"}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests that enforce credential registry completeness and consistency.
|
||||
|
||||
These tests run in CI and catch common mistakes when adding new integrations:
|
||||
- Missing health checker for a spec with health_check_endpoint
|
||||
- Orphaned entries in HEALTH_CHECKERS (no corresponding spec)
|
||||
- CredentialSpec fields that are incomplete
|
||||
- Duplicate env var conflicts
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
from aden_tools.credentials.health_check import HEALTH_CHECKERS, validate_integration_wiring
|
||||
|
||||
|
||||
class TestRegistryCompleteness:
|
||||
"""Every credential with a health_check_endpoint must have a registered checker."""
|
||||
|
||||
# Credentials that intentionally don't have their own dedicated checker:
|
||||
# - google_cse: shares google_search checker (same credential_group)
|
||||
# - razorpay/razorpay_secret: requires HTTP Basic auth with TWO credentials,
|
||||
# which the single-value health check dispatcher can't support
|
||||
KNOWN_EXCEPTIONS = {"google_cse", "razorpay", "razorpay_secret"}
|
||||
|
||||
def test_specs_with_endpoint_have_checkers(self):
|
||||
"""Every CredentialSpec with health_check_endpoint has a HEALTH_CHECKERS entry."""
|
||||
missing = []
|
||||
for name, spec in CREDENTIAL_SPECS.items():
|
||||
if name in self.KNOWN_EXCEPTIONS:
|
||||
continue
|
||||
if spec.health_check_endpoint and name not in HEALTH_CHECKERS:
|
||||
missing.append(
|
||||
f"{name}: has endpoint '{spec.health_check_endpoint}' "
|
||||
f"but no dedicated health checker"
|
||||
)
|
||||
assert not missing, (
|
||||
f"{len(missing)} credential(s) have health_check_endpoint but no checker:\n"
|
||||
+ "\n".join(f" - {m}" for m in missing)
|
||||
)
|
||||
|
||||
def test_checkers_have_corresponding_specs(self):
|
||||
"""Every key in HEALTH_CHECKERS matches a CREDENTIAL_SPECS entry."""
|
||||
orphaned = [name for name in HEALTH_CHECKERS if name not in CREDENTIAL_SPECS]
|
||||
assert not orphaned, f"HEALTH_CHECKERS has entries with no CREDENTIAL_SPECS: {orphaned}"
|
||||
|
||||
|
||||
class TestSpecRequiredFields:
|
||||
"""Every CredentialSpec should have minimum required fields."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cred_name,spec",
|
||||
list(CREDENTIAL_SPECS.items()),
|
||||
ids=list(CREDENTIAL_SPECS.keys()),
|
||||
)
|
||||
def test_has_env_var(self, cred_name, spec):
|
||||
assert spec.env_var, f"{cred_name}: missing env_var"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cred_name,spec",
|
||||
list(CREDENTIAL_SPECS.items()),
|
||||
ids=list(CREDENTIAL_SPECS.keys()),
|
||||
)
|
||||
def test_has_description(self, cred_name, spec):
|
||||
assert spec.description, f"{cred_name}: missing description"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cred_name,spec",
|
||||
list(CREDENTIAL_SPECS.items()),
|
||||
ids=list(CREDENTIAL_SPECS.keys()),
|
||||
)
|
||||
def test_has_tools_or_node_types(self, cred_name, spec):
|
||||
assert spec.tools or spec.node_types, (
|
||||
f"{cred_name}: must have at least one tool or node_type"
|
||||
)
|
||||
|
||||
|
||||
class TestNoDuplicateEnvVars:
|
||||
"""No two credential specs should use the same env_var (unless in same credential_group)."""
|
||||
|
||||
def test_no_accidental_env_var_collisions(self):
|
||||
seen: dict[str, list[str]] = {}
|
||||
for name, spec in CREDENTIAL_SPECS.items():
|
||||
seen.setdefault(spec.env_var, []).append(name)
|
||||
|
||||
duplicates = {}
|
||||
for env_var, names in seen.items():
|
||||
if len(names) <= 1:
|
||||
continue
|
||||
# Filter out intentional duplicates (same credential_group)
|
||||
groups = {CREDENTIAL_SPECS[n].credential_group for n in names}
|
||||
if len(groups) == 1 and groups != {""}:
|
||||
continue # All share the same non-empty group -- intentional
|
||||
duplicates[env_var] = names
|
||||
|
||||
assert not duplicates, f"Duplicate env_vars across unrelated credentials: {duplicates}"
|
||||
|
||||
|
||||
class TestIntegrationWiring:
|
||||
"""validate_integration_wiring() catches wiring issues."""
|
||||
|
||||
def test_nonexistent_credential(self):
|
||||
issues = validate_integration_wiring("nonexistent_service_xyz")
|
||||
assert any("No CredentialSpec" in i for i in issues)
|
||||
|
||||
def test_known_credential_no_critical_issues(self):
|
||||
"""A well-wired credential (e.g. 'hubspot') should have no issues."""
|
||||
issues = validate_integration_wiring("hubspot")
|
||||
assert not issues, f"Unexpected issues for hubspot: {issues}"
|
||||
|
||||
@pytest.mark.parametrize("cred_name", list(HEALTH_CHECKERS.keys()))
|
||||
def test_all_checkers_pass_wiring(self, cred_name):
|
||||
"""Every registered checker should pass wiring validation."""
|
||||
issues = validate_integration_wiring(cred_name)
|
||||
assert not issues, f"Wiring issues for '{cred_name}':\n" + "\n".join(
|
||||
f" - {i}" for i in issues
|
||||
)
|
||||
@@ -7,12 +7,22 @@ import httpx
|
||||
from aden_tools.credentials.health_check import (
|
||||
HEALTH_CHECKERS,
|
||||
AnthropicHealthChecker,
|
||||
ApolloHealthChecker,
|
||||
BrevoHealthChecker,
|
||||
CalcomHealthChecker,
|
||||
DiscordHealthChecker,
|
||||
ExaSearchHealthChecker,
|
||||
FinlightHealthChecker,
|
||||
GitHubHealthChecker,
|
||||
GoogleCalendarHealthChecker,
|
||||
GoogleDocsHealthChecker,
|
||||
GoogleMapsHealthChecker,
|
||||
GoogleSearchHealthChecker,
|
||||
NewsdataHealthChecker,
|
||||
ResendHealthChecker,
|
||||
SerpApiHealthChecker,
|
||||
StripeHealthChecker,
|
||||
TelegramHealthChecker,
|
||||
check_credential_health,
|
||||
)
|
||||
|
||||
@@ -69,6 +79,16 @@ class TestHealthCheckerRegistry:
|
||||
"google",
|
||||
"slack",
|
||||
"discord",
|
||||
"stripe",
|
||||
"exa_search",
|
||||
"google_docs",
|
||||
"calcom",
|
||||
"serpapi",
|
||||
"apollo",
|
||||
"telegram",
|
||||
"newsdata",
|
||||
"finlight",
|
||||
"brevo",
|
||||
}
|
||||
assert set(HEALTH_CHECKERS.keys()) == expected
|
||||
|
||||
@@ -485,3 +505,199 @@ class TestGoogleCalendarHealthCheckerTokenSanitization:
|
||||
|
||||
assert not result.valid
|
||||
assert "Connection refused" in result.message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HealthCheckerTestSuite: reusable base class for standard test scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HealthCheckerTestSuite:
|
||||
"""Reusable test mixin that auto-generates standard health check scenarios.
|
||||
|
||||
Subclass this and set ``CHECKER_CLASS`` and ``HTTP_METHOD`` to get 6 tests
|
||||
for free. Add checker-specific tests alongside as needed.
|
||||
|
||||
Example::
|
||||
|
||||
class TestMyNewChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = MyNewHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
"""
|
||||
|
||||
CHECKER_CLASS: type | None = None
|
||||
HTTP_METHOD: str = "get"
|
||||
CHECKER_KWARGS: dict = {}
|
||||
|
||||
# Override these if the checker uses non-standard valid-status logic
|
||||
EXPECT_200_VALID: bool = True
|
||||
EXPECT_401_INVALID: bool = True
|
||||
EXPECT_403_INVALID: bool = True
|
||||
EXPECT_429_VALID: bool = True
|
||||
|
||||
def _make_checker(self):
|
||||
assert self.CHECKER_CLASS is not None, "Set CHECKER_CLASS in subclass"
|
||||
return self.CHECKER_CLASS(**self.CHECKER_KWARGS)
|
||||
|
||||
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
|
||||
else:
|
||||
response.json.return_value = {}
|
||||
return response
|
||||
|
||||
def _setup_mock(self, mock_client_cls, status_code=200, json_data=None):
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
http_method = getattr(mock_client, self.HTTP_METHOD)
|
||||
http_method.return_value = self._mock_response(status_code, json_data)
|
||||
return mock_client, http_method
|
||||
|
||||
@patch("aden_tools.credentials.health_check.httpx.Client")
|
||||
def test_valid_credential_200(self, mock_client_cls):
|
||||
"""200 response means valid credential."""
|
||||
if not self.EXPECT_200_VALID:
|
||||
return
|
||||
self._setup_mock(mock_client_cls, 200)
|
||||
result = self._make_checker().check("test-credential")
|
||||
assert result.valid is True
|
||||
|
||||
@patch("aden_tools.credentials.health_check.httpx.Client")
|
||||
def test_invalid_credential_401(self, mock_client_cls):
|
||||
"""401 response means invalid credential."""
|
||||
if not self.EXPECT_401_INVALID:
|
||||
return
|
||||
self._setup_mock(mock_client_cls, 401)
|
||||
result = self._make_checker().check("bad-credential")
|
||||
assert result.valid is False
|
||||
assert result.details.get("status_code") == 401
|
||||
|
||||
@patch("aden_tools.credentials.health_check.httpx.Client")
|
||||
def test_forbidden_403(self, mock_client_cls):
|
||||
"""403 response means insufficient permissions."""
|
||||
if not self.EXPECT_403_INVALID:
|
||||
return
|
||||
self._setup_mock(mock_client_cls, 403)
|
||||
result = self._make_checker().check("test-credential")
|
||||
assert result.valid is False
|
||||
assert result.details.get("status_code") == 403
|
||||
|
||||
@patch("aden_tools.credentials.health_check.httpx.Client")
|
||||
def test_rate_limited_429(self, mock_client_cls):
|
||||
"""429 (rate limited) typically means the credential is valid."""
|
||||
if not self.EXPECT_429_VALID:
|
||||
return
|
||||
self._setup_mock(mock_client_cls, 429)
|
||||
result = self._make_checker().check("test-credential")
|
||||
assert result.valid is True
|
||||
|
||||
@patch("aden_tools.credentials.health_check.httpx.Client")
|
||||
def test_timeout(self, mock_client_cls):
|
||||
"""Timeout is handled gracefully."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
getattr(mock_client, self.HTTP_METHOD).side_effect = httpx.TimeoutException("timed out")
|
||||
|
||||
result = self._make_checker().check("test-credential")
|
||||
assert result.valid is False
|
||||
assert result.details.get("error") == "timeout"
|
||||
|
||||
@patch("aden_tools.credentials.health_check.httpx.Client")
|
||||
def test_network_error(self, mock_client_cls):
|
||||
"""Network errors are handled gracefully."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
getattr(mock_client, self.HTTP_METHOD).side_effect = httpx.RequestError(
|
||||
"connection refused"
|
||||
)
|
||||
|
||||
result = self._make_checker().check("test-credential")
|
||||
assert result.valid is False
|
||||
assert "error" in result.details
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for new checkers (using HealthCheckerTestSuite)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStripeHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = StripeHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
|
||||
|
||||
class TestExaSearchHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = ExaSearchHealthChecker
|
||||
HTTP_METHOD = "post"
|
||||
|
||||
|
||||
class TestGoogleDocsHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = GoogleDocsHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
# OAuthBearerHealthChecker doesn't treat 429 as valid
|
||||
EXPECT_429_VALID = False
|
||||
|
||||
|
||||
class TestCalcomHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = CalcomHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
|
||||
|
||||
class TestSerpApiHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = SerpApiHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
|
||||
|
||||
class TestApolloHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = ApolloHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
|
||||
|
||||
class TestTelegramHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = TelegramHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
# Telegram returns 200 with {"ok": true/false} rather than using HTTP status codes
|
||||
EXPECT_429_VALID = False
|
||||
|
||||
@patch("aden_tools.credentials.health_check.httpx.Client")
|
||||
def test_valid_credential_200(self, mock_client_cls):
|
||||
"""200 with ok=true means valid bot token."""
|
||||
self._setup_mock(
|
||||
mock_client_cls,
|
||||
200,
|
||||
{"ok": True, "result": {"username": "testbot"}},
|
||||
)
|
||||
result = self._make_checker().check("123:ABC")
|
||||
assert result.valid is True
|
||||
assert "testbot" in result.message
|
||||
|
||||
@patch("aden_tools.credentials.health_check.httpx.Client")
|
||||
def test_ok_false_invalid(self, mock_client_cls):
|
||||
"""200 with ok=false means invalid bot token."""
|
||||
self._setup_mock(
|
||||
mock_client_cls,
|
||||
200,
|
||||
{"ok": False, "description": "Unauthorized"},
|
||||
)
|
||||
result = self._make_checker().check("bad-token")
|
||||
assert result.valid is False
|
||||
|
||||
|
||||
class TestNewsdataHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = NewsdataHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
|
||||
|
||||
class TestFinlightHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = FinlightHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
|
||||
|
||||
class TestBrevoHealthChecker(HealthCheckerTestSuite):
|
||||
CHECKER_CLASS = BrevoHealthChecker
|
||||
HTTP_METHOD = "get"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Tests for the arXiv search and download tool.
|
||||
|
||||
Covers:
|
||||
- search_papers: success, id_list lookup, validation, sorting, error handling
|
||||
- download_paper: success, missing paper, no PDF URL, network error,
|
||||
bad content type, file cleanup on error
|
||||
- Tool registration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import arxiv
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from aden_tools.tools.arxiv_tool.arxiv_tool import register_tools
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mcp() -> FastMCP:
|
||||
mcp = FastMCP("test-arxiv")
|
||||
register_tools(mcp)
|
||||
return mcp
|
||||
|
||||
|
||||
def _get_tool(mcp: FastMCP, name: str):
|
||||
"""Return the raw callable for a registered tool by name."""
|
||||
return mcp._tool_manager._tools[name].fn
|
||||
|
||||
|
||||
def _make_arxiv_result(
|
||||
short_id="1706.03762",
|
||||
title="Attention Is All You Need",
|
||||
summary="We propose a new simple network architecture...",
|
||||
published="2017-06-12",
|
||||
authors=("Vaswani",),
|
||||
pdf_url="https://arxiv.org/pdf/1706.03762",
|
||||
categories=("cs.CL",),
|
||||
) -> MagicMock:
|
||||
"""Build a minimal mock arxiv.Result."""
|
||||
result = MagicMock()
|
||||
result.get_short_id.return_value = short_id
|
||||
result.title = title
|
||||
result.summary = summary
|
||||
result.published.date.return_value = published
|
||||
result.authors = [MagicMock(name=a) for a in authors]
|
||||
result.pdf_url = pdf_url
|
||||
result.categories = list(categories)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolRegistration:
|
||||
def test_all_tools_registered(self):
|
||||
mcp = _make_mcp()
|
||||
registered = set(mcp._tool_manager._tools.keys())
|
||||
assert "search_papers" in registered
|
||||
assert "download_paper" in registered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_papers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearchPapers:
|
||||
def setup_method(self):
|
||||
self.mcp = _make_mcp()
|
||||
self.search_papers = _get_tool(self.mcp, "search_papers")
|
||||
|
||||
def test_validation_error_missing_params(self):
|
||||
result = self.search_papers(query="", id_list=None)
|
||||
assert result["success"] is False
|
||||
assert "query" in result["error"] or "id_list" in result["error"]
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_search_success(self, mock_client):
|
||||
mock_client.results.return_value = iter([_make_arxiv_result()])
|
||||
|
||||
result = self.search_papers(query="attention transformer")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["total"] == 1
|
||||
paper = result["results"][0]
|
||||
assert paper["id"] == "1706.03762"
|
||||
assert paper["title"] == "Attention Is All You Need"
|
||||
assert paper["pdf_url"] == "https://arxiv.org/pdf/1706.03762"
|
||||
assert "cs.CL" in paper["categories"]
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_search_success_with_results(self, mock_client):
|
||||
mock_client.results.return_value = iter(
|
||||
[_make_arxiv_result(short_id=f"000{i}.0000{i}") for i in range(3)]
|
||||
)
|
||||
result = self.search_papers(query="multi-agent systems", max_results=3)
|
||||
assert result["success"] is True
|
||||
assert result["total"] == 3
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_search_by_id_list(self, mock_client):
|
||||
mock_client.results.return_value = iter([_make_arxiv_result()])
|
||||
|
||||
result = self.search_papers(id_list=["1706.03762"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["id_list"] == ["1706.03762"]
|
||||
assert result["query"] == ""
|
||||
|
||||
def test_max_results_clamped(self):
|
||||
"""max_results above 100 should be silently capped — confirm no crash."""
|
||||
with patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT") as mock_client:
|
||||
mock_client.results.return_value = iter([])
|
||||
result = self.search_papers(query="test", max_results=9999)
|
||||
assert result["success"] is True
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_arxiv_error_handling(self, mock_client):
|
||||
mock_client.results.side_effect = arxiv.ArxivError(
|
||||
message="arXiv is down", url="", retry=False
|
||||
)
|
||||
result = self.search_papers(query="test")
|
||||
assert result["success"] is False
|
||||
assert "arXiv" in result["error"]
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_network_error_handling(self, mock_client):
|
||||
mock_client.results.side_effect = ConnectionError("unreachable")
|
||||
result = self.search_papers(query="test")
|
||||
assert result["success"] is False
|
||||
assert "unreachable" in result["error"].lower() or "network" in result["error"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# download_paper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDownloadPaper:
|
||||
def setup_method(self):
|
||||
self.mcp = _make_mcp()
|
||||
self.download_paper = _get_tool(self.mcp, "download_paper")
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool.requests.get")
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_download_success(self, mock_client, mock_get, tmp_path):
|
||||
mock_client.results.return_value = iter([_make_arxiv_result()])
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.headers = {"Content-Type": "application/pdf"}
|
||||
mock_response.iter_content.return_value = [b"%PDF-1.4 fake content"]
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with patch("aden_tools.tools.arxiv_tool.arxiv_tool._TEMP_DIR") as mock_tmp:
|
||||
mock_tmp.name = str(tmp_path)
|
||||
result = self.download_paper(paper_id="1706.03762")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["paper_id"] == "1706.03762"
|
||||
assert result["file_path"].endswith(".pdf")
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_no_paper_found(self, mock_client):
|
||||
mock_client.results.return_value = iter([])
|
||||
result = self.download_paper(paper_id="0000.00000")
|
||||
assert result["success"] is False
|
||||
assert "No paper found" in result["error"]
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_no_pdf_url(self, mock_client):
|
||||
paper = _make_arxiv_result(pdf_url=None)
|
||||
mock_client.results.return_value = iter([paper])
|
||||
result = self.download_paper(paper_id="1706.03762")
|
||||
assert result["success"] is False
|
||||
assert "PDF URL not available" in result["error"]
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool.requests.get")
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_download_network_error(self, mock_client, mock_get):
|
||||
import requests
|
||||
|
||||
mock_client.results.return_value = iter([_make_arxiv_result()])
|
||||
mock_get.side_effect = requests.RequestException("connection refused")
|
||||
|
||||
result = self.download_paper(paper_id="1706.03762")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Failed during download" in result["error"]
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool.requests.get")
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_download_invalid_content_type(self, mock_client, mock_get):
|
||||
mock_client.results.return_value = iter([_make_arxiv_result()])
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.headers = {"Content-Type": "text/html"}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result = self.download_paper(paper_id="1706.03762")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Failed during download" in result["error"]
|
||||
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool.requests.get")
|
||||
@patch("aden_tools.tools.arxiv_tool.arxiv_tool._SHARED_ARXIV_CLIENT")
|
||||
def test_file_cleanup_on_error(self, mock_client, mock_get, tmp_path):
|
||||
"""Partial file must be deleted when the download fails mid-write."""
|
||||
import requests
|
||||
|
||||
mock_client.results.return_value = iter([_make_arxiv_result()])
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.headers = {"Content-Type": "application/pdf"}
|
||||
mock_response.iter_content.side_effect = requests.RequestException("dropped")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with patch("aden_tools.tools.arxiv_tool.arxiv_tool._TEMP_DIR") as mock_tmp:
|
||||
mock_tmp.name = str(tmp_path)
|
||||
result = self.download_paper(paper_id="1706.03762")
|
||||
|
||||
assert result["success"] is False
|
||||
# No leftover partial files
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Tests for PostgreSQL MCP tools (refactored single-file version).
|
||||
"""
|
||||
|
||||
import psycopg2 as psycopg
|
||||
import pytest
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from aden_tools.tools.postgres_tool import register_tools
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp():
|
||||
return FastMCP("test-server")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_database_url(monkeypatch):
|
||||
"""
|
||||
Prevent DATABASE_URL requirement during tests.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"aden_tools.tools.postgres_tool.postgres_tool._get_database_url",
|
||||
lambda credentials: "postgresql://fake-url",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Database Mocking
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _mock_db(monkeypatch):
|
||||
class FakeCursor:
|
||||
description = [type("D", (), {"name": "col"})]
|
||||
|
||||
def execute(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def fetchmany(self, n):
|
||||
return [["value"]]
|
||||
|
||||
def fetchall(self):
|
||||
return [
|
||||
("public",),
|
||||
("example_schema",),
|
||||
]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
class FakeConn:
|
||||
def set_session(self, **kwargs):
|
||||
pass # needed because readonly=True is called
|
||||
|
||||
def cursor(self):
|
||||
return FakeCursor()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aden_tools.tools.postgres_tool.postgres_tool._get_connection",
|
||||
lambda database_url: FakeConn(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_query_fn(mcp: FastMCP, monkeypatch):
|
||||
_mock_db(monkeypatch)
|
||||
register_tools(mcp)
|
||||
return mcp._tool_manager._tools["pg_query"].fn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_list_schemas_fn(mcp: FastMCP, monkeypatch):
|
||||
_mock_db(monkeypatch)
|
||||
register_tools(mcp)
|
||||
return mcp._tool_manager._tools["pg_list_schemas"].fn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_list_tables_fn(mcp: FastMCP, monkeypatch):
|
||||
_mock_db(monkeypatch)
|
||||
register_tools(mcp)
|
||||
return mcp._tool_manager._tools["pg_list_tables"].fn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_describe_table_fn(mcp: FastMCP, monkeypatch):
|
||||
_mock_db(monkeypatch)
|
||||
register_tools(mcp)
|
||||
return mcp._tool_manager._tools["pg_describe_table"].fn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_explain_fn(mcp: FastMCP, monkeypatch):
|
||||
_mock_db(monkeypatch)
|
||||
register_tools(mcp)
|
||||
return mcp._tool_manager._tools["pg_explain"].fn
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestPgQuery:
|
||||
def test_simple_select(self, pg_query_fn):
|
||||
result = pg_query_fn(sql="SELECT 1")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["row_count"] == 1
|
||||
assert isinstance(result["columns"], list)
|
||||
assert isinstance(result["rows"], list)
|
||||
|
||||
def test_invalid_sql_returns_error(self, pg_query_fn, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"aden_tools.tools.postgres_tool.postgres_tool.validate_sql",
|
||||
lambda _: (_ for _ in ()).throw(ValueError("Invalid SQL")),
|
||||
)
|
||||
|
||||
result = pg_query_fn(sql="DROP TABLE x")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_query_timeout(self, pg_query_fn, monkeypatch):
|
||||
class TimeoutCursor:
|
||||
def execute(self, *args, **kwargs):
|
||||
raise psycopg.errors.QueryCanceled()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
class TimeoutConn:
|
||||
def set_session(self, **kwargs):
|
||||
pass
|
||||
|
||||
def cursor(self):
|
||||
return TimeoutCursor()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aden_tools.tools.postgres_tool.postgres_tool._get_connection",
|
||||
lambda database_url: TimeoutConn(),
|
||||
)
|
||||
|
||||
result = pg_query_fn(sql="SELECT pg_sleep(10)")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "timed out" in result["error"].lower()
|
||||
|
||||
|
||||
class TestPgListSchemas:
|
||||
def test_list_schemas_success(self, pg_list_schemas_fn):
|
||||
result = pg_list_schemas_fn()
|
||||
|
||||
assert result["success"] is True
|
||||
assert isinstance(result["result"], list)
|
||||
assert all(isinstance(x, str) for x in result["result"])
|
||||
|
||||
|
||||
class TestPgListTables:
|
||||
def test_list_tables_all(self, pg_list_tables_fn):
|
||||
result = pg_list_tables_fn()
|
||||
assert result["success"] is True
|
||||
assert isinstance(result["result"], list)
|
||||
|
||||
def test_list_tables_with_schema(self, pg_list_tables_fn):
|
||||
result = pg_list_tables_fn(schema="any_schema")
|
||||
assert result["success"] is True
|
||||
assert isinstance(result["result"], list)
|
||||
|
||||
|
||||
class TestPgDescribeTable:
|
||||
def test_describe_table_success(self, pg_describe_table_fn, monkeypatch):
|
||||
class DescribeCursor:
|
||||
def execute(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def fetchall(self):
|
||||
return [
|
||||
("col_a", "bigint", False, None),
|
||||
("col_b", "text", True, "default"),
|
||||
]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
class DescribeConn:
|
||||
def set_session(self, **kwargs):
|
||||
pass
|
||||
|
||||
def cursor(self):
|
||||
return DescribeCursor()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aden_tools.tools.postgres_tool.postgres_tool._get_connection",
|
||||
lambda database_url: DescribeConn(),
|
||||
)
|
||||
|
||||
result = pg_describe_table_fn(
|
||||
schema="any_schema",
|
||||
table="any_table",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert isinstance(result["result"], list)
|
||||
assert len(result["result"]) == 2
|
||||
|
||||
column = result["result"][0]
|
||||
assert set(column.keys()) == {"column", "type", "nullable", "default"}
|
||||
|
||||
|
||||
class TestPgExplain:
|
||||
def test_explain_success(self, pg_explain_fn):
|
||||
result = pg_explain_fn(sql="SELECT 1")
|
||||
|
||||
assert result["success"] is True
|
||||
assert isinstance(result["result"], list)
|
||||
|
||||
def test_explain_invalid_sql(self, pg_explain_fn, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"aden_tools.tools.postgres_tool.postgres_tool.validate_sql",
|
||||
lambda _: (_ for _ in ()).throw(ValueError("Invalid SQL")),
|
||||
)
|
||||
|
||||
result = pg_explain_fn(sql="DELETE FROM x")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
@@ -0,0 +1,109 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from aden_tools.tools.wikipedia_tool.wikipedia_tool import register_tools
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp():
|
||||
return FastMCP("test-server")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_func(mcp):
|
||||
"""Register the tool and return the callable function."""
|
||||
register_tools(mcp)
|
||||
# FastMCP stores tools in _tools dictionary usually, or we can just access
|
||||
# the decorated function if we extracted it. Since register_tools uses
|
||||
# @mcp.tool(), let's extract the function logic or call via mcp if possible.
|
||||
# For unit testing the logic, it's easier if we can access the underlying function.
|
||||
|
||||
# But register_tools defines the function *inside* the scope.
|
||||
# So we'll need to rely on how FastMCP exposes tools or refactor slightly?
|
||||
# Actually, looking at other tests might help, but let's assume standard FastMCP behavior.
|
||||
# If FastMCP.tool() returns the function, we can capture it.
|
||||
# But here register_tools returns None.
|
||||
|
||||
# Workaround: We can inspect mcp._tools (if it exists) or use a mock mcp
|
||||
# to capture the decorator.
|
||||
|
||||
tools = {}
|
||||
mock_mcp = MagicMock()
|
||||
|
||||
def mock_tool():
|
||||
def decorator(f):
|
||||
tools[f.__name__] = f
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
mock_mcp.tool = mock_tool
|
||||
|
||||
register_tools(mock_mcp)
|
||||
return tools["search_wikipedia"]
|
||||
|
||||
|
||||
def test_search_wikipedia_success(tool_func):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"pages": [
|
||||
{
|
||||
"title": "Artificial Intelligence",
|
||||
"key": "Artificial_Intelligence",
|
||||
"description": "Intelligence demonstrated by machines",
|
||||
"excerpt": "<b>Artificial intelligence</b> (<b>AI</b>)...",
|
||||
},
|
||||
{
|
||||
"title": "AI Winter",
|
||||
"key": "AI_Winter",
|
||||
"description": "Period of reduced funding",
|
||||
"excerpt": "In the history of AI...",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
patch_target = "aden_tools.tools.wikipedia_tool.wikipedia_tool.httpx.get"
|
||||
with patch(patch_target, return_value=mock_response) as mock_get:
|
||||
result = tool_func(query="AI")
|
||||
|
||||
assert result["query"] == "AI"
|
||||
assert result["count"] == 2
|
||||
assert result["results"][0]["title"] == "Artificial Intelligence"
|
||||
assert "Artificial_Intelligence" in result["results"][0]["url"]
|
||||
# Verify HTML stripping
|
||||
assert "<b>" not in result["results"][0]["snippet"]
|
||||
assert "Artificial intelligence (AI)..." in result["results"][0]["snippet"]
|
||||
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
assert kwargs["params"]["q"] == "AI"
|
||||
|
||||
|
||||
def test_search_wikipedia_empty_query(tool_func):
|
||||
result = tool_func(query="")
|
||||
assert "error" in result
|
||||
assert result["error"] == "Query cannot be empty"
|
||||
|
||||
|
||||
def test_search_wikipedia_api_error(tool_func):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
patch_target = "aden_tools.tools.wikipedia_tool.wikipedia_tool.httpx.get"
|
||||
with patch(patch_target, return_value=mock_response):
|
||||
result = tool_func(query="Error")
|
||||
assert "error" in result
|
||||
assert "Wikipedia API error: 500" in result["error"]
|
||||
|
||||
|
||||
def test_search_wikipedia_timeout(tool_func):
|
||||
import httpx
|
||||
|
||||
patch_target = "aden_tools.tools.wikipedia_tool.wikipedia_tool.httpx.get"
|
||||
with patch(patch_target, side_effect=httpx.TimeoutException("Timeout")):
|
||||
result = tool_func(query="Timeout")
|
||||
assert "error" in result
|
||||
assert "Request timed out" in result["error"]
|
||||
@@ -184,6 +184,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arxiv"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "feedparser" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/aa/dc1c6c633f63fce090e7c067af8c528a5e61218a61c266ff615d46cbde0a/arxiv-2.4.0.tar.gz", hash = "sha256:cabe5470d031aa3f22d2744a7600391c62c3489653f0c62bec9019e62bb0554b", size = 74546, upload-time = "2026-01-05T02:43:16.823Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/63/9e71153b2d48c98f8079c90d7211bc65515cc1ad18c3328c3c0472e68f44/arxiv-2.4.0-py3-none-any.whl", hash = "sha256:c02ccb09a777aaadd75d3bc1d2627894ef9c987c651d0dacd864b9f69fb0569f", size = 12065, upload-time = "2026-01-05T02:43:12.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "5.0.1"
|
||||
@@ -755,6 +768,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "feedparser"
|
||||
version = "6.0.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sgmllib3k" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.20.3"
|
||||
@@ -2384,6 +2409,58 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-aio"
|
||||
version = "0.3.0"
|
||||
@@ -3198,6 +3275,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sgmllib3k"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" }
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
@@ -3269,6 +3352,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stripe"
|
||||
version = "14.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/67/8a38222a57fc2ba359c4dcb66528d94c00d803c7fde8f8d8470ad6bdccbb/stripe-14.3.0.tar.gz", hash = "sha256:4c76137d741bd43e8bb433a596c198ca20f4cdf17a8fe04604faf37c74b01978", size = 1463618, upload-time = "2026-01-28T21:20:29.856Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4b/0b7d5920f2be5e42d72bdfc44a9fae57b422668bfc8dacdf2f74886f6daa/stripe-14.3.0-py3-none-any.whl", hash = "sha256:3e36b68b256c8970e99b703e195d947e2a2919095758788c7074ac4485ac255e", size = 2106980, upload-time = "2026-01-28T21:20:27.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textual"
|
||||
version = "7.5.0"
|
||||
@@ -3371,6 +3467,7 @@ name = "tools"
|
||||
version = "0.1.0"
|
||||
source = { editable = "tools" }
|
||||
dependencies = [
|
||||
{ name = "arxiv" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "diff-match-patch" },
|
||||
{ name = "dnspython" },
|
||||
@@ -3382,10 +3479,13 @@ dependencies = [
|
||||
{ name = "pandas" },
|
||||
{ name = "playwright" },
|
||||
{ name = "playwright-stealth" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pypdf" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "requests" },
|
||||
{ name = "resend" },
|
||||
{ name = "stripe" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -3427,6 +3527,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "arxiv", specifier = ">=2.1.0" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
|
||||
{ name = "diff-match-patch", specifier = ">=20230430" },
|
||||
{ name = "dnspython", specifier = ">=2.4.0" },
|
||||
@@ -3446,6 +3547,7 @@ requires-dist = [
|
||||
{ name = "pillow", marker = "extra == 'ocr'", specifier = ">=10.0.0" },
|
||||
{ name = "playwright", specifier = ">=1.40.0" },
|
||||
{ name = "playwright-stealth", specifier = ">=1.0.5" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pypdf", specifier = ">=4.0.0" },
|
||||
{ name = "pytesseract", marker = "extra == 'all'", specifier = ">=0.3.10" },
|
||||
@@ -3453,9 +3555,11 @@ requires-dist = [
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
{ name = "resend", specifier = ">=2.0.0" },
|
||||
{ name = "restrictedpython", marker = "extra == 'all'", specifier = ">=7.0" },
|
||||
{ name = "restrictedpython", marker = "extra == 'sandbox'", specifier = ">=7.0" },
|
||||
{ name = "stripe", specifier = ">=14.3.0" },
|
||||
]
|
||||
provides-extras = ["dev", "sandbox", "ocr", "excel", "sql", "bigquery", "all"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user