Merge pull request #5499 from aden-hive/feat/open-hive
Release / Create Release (push) Waiting to run

feat: tool call revamp, Intercom & GA integrations, credential improvements
This commit is contained in:
RichardTang-Aden
2026-02-27 19:41:11 -08:00
committed by GitHub
47 changed files with 3276 additions and 770 deletions
@@ -195,7 +195,7 @@ class DeepResearchAgent:
max_tokens=self.config.max_tokens,
loop_config={
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
},
conversation_mode="continuous",
@@ -71,6 +71,12 @@ Important:
- Track which URL each finding comes from (you'll need citations later)
- Call set_output for each key in a SEPARATE turn (not in the same turn as other tool calls)
Context management:
- Your tool results are automatically saved to files. After compaction, the file \
references remain in the conversation use load_data() to recover any content you need.
- Use append_data('research_notes.md', ...) to maintain a running log of key findings \
as you go. This survives compaction and helps the report node produce a detailed report.
When done, use set_output (one key at a time, separate turns):
- set_output("findings", "Structured summary: key findings with source URLs for each claim. \
Include themes, contradictions, and confidence levels.")
@@ -161,6 +167,9 @@ Requirements:
- Every factual claim must cite its source with [n] notation
- Be objective present multiple viewpoints where sources disagree
- Answer the original research questions from the brief
- If findings appear incomplete or summarized, call list_data_files() and load_data() \
to access the detailed source material from the research phase. The research node's \
tool results and research_notes.md contain the full data.
Save the HTML:
save_data(filename="report.html", data="<html>...</html>")
+1 -1
View File
@@ -1768,7 +1768,7 @@ async def _run_pipeline(websocket, initial_message: str):
judge=judge,
config=LoopConfig(
max_iterations=30,
max_tool_calls_per_turn=15,
max_tool_calls_per_turn=30,
max_history_tokens=64000,
max_tool_result_chars=8_000,
spillover_dir=str(_DATA_DIR),
+2 -2
View File
@@ -751,7 +751,7 @@ async def _run_pipeline(websocket, topic: str):
judge=None, # implicit judge: accept when output_keys filled
config=LoopConfig(
max_iterations=20,
max_tool_calls_per_turn=10,
max_tool_calls_per_turn=30,
max_history_tokens=32_000,
),
conversation_store=store_a,
@@ -849,7 +849,7 @@ async def _run_pipeline(websocket, topic: str):
judge=None, # implicit judge
config=LoopConfig(
max_iterations=10,
max_tool_calls_per_turn=5,
max_tool_calls_per_turn=30,
max_history_tokens=32_000,
),
conversation_store=store_b,
+1 -1
View File
@@ -1257,7 +1257,7 @@ async def _run_org_pipeline(websocket, topic: str):
judge=judge,
config=LoopConfig(
max_iterations=30,
max_tool_calls_per_turn=25,
max_tool_calls_per_turn=30,
max_history_tokens=32_000,
),
conversation_store=store,
@@ -453,7 +453,7 @@ identity_prompt = (
)
loop_config = {
"max_iterations": 50,
"max_tool_calls_per_turn": 10,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
}
@@ -539,7 +539,7 @@ class CredentialTesterAgent:
max_tokens=self.config.max_tokens,
loop_config={
"max_iterations": 50,
"max_tool_calls_per_turn": 10,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
},
conversation_mode="continuous",
+3 -3
View File
@@ -127,7 +127,7 @@ identity_prompt = (
)
loop_config = {
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
}
@@ -160,8 +160,8 @@ queen_graph = GraphSpec(
edges=[],
conversation_mode="continuous",
loop_config={
"max_iterations": 200,
"max_tool_calls_per_turn": 10,
"max_iterations": 999_999,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
},
)
@@ -351,7 +351,7 @@ value. These DO NOT EXIST.
```python
loop_config = {
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
}
```
@@ -469,7 +469,7 @@ Most agents use `terminal_nodes=[]` (forever-alive). This means \
terminal node that doesn't exist. Agent tests MUST be structural:
- Validate graph, node specs, edges, tools, prompts
- Check goal/constraints/success criteria definitions
- Test `AgentRunner.load()` + `_setup()` (skip if no API key)
- Test `AgentRunner.load()` succeeds (structural, no API key needed)
- NEVER call `runner.run()` or `trigger_and_wait()` in tests for \
forever-alive agents they will hang and time out.
When you restructure an agent (change nodes/edges), always update \
@@ -80,7 +80,7 @@ One client-facing node handles ALL user interaction (setup, logging, reports). O
- Validate graph structure (nodes, edges, entry points)
- Verify node specs (tools, prompts, client-facing flag)
- Check goal/constraints/success criteria definitions
- Test that `AgentRunner.load()` + `_setup()` succeeds (skip if no API key)
- Test that `AgentRunner.load()` succeeds (structural, no API key needed)
**What NOT to do:**
```python
@@ -235,16 +235,14 @@ class MyAgent:
identity_prompt=identity_prompt,
)
def _setup(self, mock_mode=False):
def _setup(self):
self._storage_path = Path.home() / ".hive" / "agents" / "my_agent"
self._storage_path.mkdir(parents=True, exist_ok=True)
self._tool_registry = ToolRegistry()
mcp_config = Path(__file__).parent / "mcp_servers.json"
if mcp_config.exists():
self._tool_registry.load_mcp_config(mcp_config)
llm = None
if not mock_mode:
llm = LiteLLMProvider(model=self.config.model, api_key=self.config.api_key, api_base=self.config.api_base)
llm = LiteLLMProvider(model=self.config.model, api_key=self.config.api_key, api_base=self.config.api_base)
tools = list(self._tool_registry.get_tools().values())
tool_executor = self._tool_registry.get_executor()
self._graph = self._build_graph()
@@ -257,9 +255,9 @@ class MyAgent:
checkpoint_max_age_days=7, async_checkpoint=True),
)
async def start(self, mock_mode=False):
async def start(self):
if self._agent_runtime is None:
self._setup(mock_mode=mock_mode)
self._setup()
if not self._agent_runtime.is_running:
await self._agent_runtime.start()
@@ -274,8 +272,8 @@ class MyAgent:
return await self._agent_runtime.trigger_and_wait(
entry_point_id=entry_point, input_data=input_data or {}, session_state=session_state)
async def run(self, context, mock_mode=False, session_state=None):
await self.start(mock_mode=mock_mode)
async def run(self, context, session_state=None):
await self.start()
try:
result = await self.trigger_and_wait("default", context, session_state=session_state)
return result or ExecutionResult(success=False, error="Execution timeout")
@@ -471,19 +469,17 @@ def cli():
@cli.command()
@click.option("--topic", "-t", required=True)
@click.option("--mock", is_flag=True)
@click.option("--verbose", "-v", is_flag=True)
def run(topic, mock, verbose):
def run(topic, verbose):
"""Execute the agent."""
setup_logging(verbose=verbose)
result = asyncio.run(default_agent.run({"topic": topic}, mock_mode=mock))
result = asyncio.run(default_agent.run({"topic": topic}))
click.echo(json.dumps({"success": result.success, "output": result.output}, indent=2, default=str))
sys.exit(0 if result.success else 1)
@cli.command()
@click.option("--mock", is_flag=True)
def tui(mock):
def tui():
"""Launch TUI dashboard."""
from pathlib import Path
from framework.tui.app import AdenTUI
@@ -499,7 +495,7 @@ def tui(mock):
storage.mkdir(parents=True, exist_ok=True)
mcp_cfg = Path(__file__).parent / "mcp_servers.json"
if mcp_cfg.exists(): agent._tool_registry.load_mcp_config(mcp_cfg)
llm = None if mock else LiteLLMProvider(model=agent.config.model, api_key=agent.config.api_key, api_base=agent.config.api_base)
llm = LiteLLMProvider(model=agent.config.model, api_key=agent.config.api_key, api_base=agent.config.api_base)
runtime = create_agent_runtime(
graph=agent._build_graph(), goal=agent.goal, storage_path=storage,
entry_points=[EntryPointSpec(id="start", name="Start", entry_node="intake", trigger_type="manual", isolation_level="isolated")],
@@ -564,7 +560,6 @@ import sys
from pathlib import Path
import pytest
import pytest_asyncio
_repo_root = Path(__file__).resolve().parents[3]
for _p in ["exports", "core"]:
@@ -576,18 +571,17 @@ AGENT_PATH = str(Path(__file__).resolve().parents[1])
@pytest.fixture(scope="session")
def mock_mode():
return True
def agent_module():
"""Import the agent package for structural validation."""
import importlib
return importlib.import_module(Path(AGENT_PATH).name)
@pytest_asyncio.fixture(scope="session")
async def runner(tmp_path_factory, mock_mode):
@pytest.fixture(scope="session")
def runner_loaded():
"""Load the agent through AgentRunner (structural only, no LLM needed)."""
from framework.runner.runner import AgentRunner
storage = tmp_path_factory.mktemp("agent_storage")
r = AgentRunner.load(AGENT_PATH, mock_mode=mock_mode, storage_path=storage)
r._setup()
yield r
await r.cleanup_async()
return AgentRunner.load(AGENT_PATH)
```
## entry_points Format
+15
View File
@@ -42,6 +42,14 @@ For Vault integration:
from core.framework.credentials.vault import HashiCorpVaultStorage
"""
from .key_storage import (
delete_aden_api_key,
generate_and_save_credential_key,
load_aden_api_key,
load_credential_key,
save_aden_api_key,
save_credential_key,
)
from .models import (
CredentialDecryptionError,
CredentialError,
@@ -132,6 +140,13 @@ __all__ = [
"CredentialRefreshError",
"CredentialValidationError",
"CredentialDecryptionError",
# Key storage (bootstrap credentials)
"load_credential_key",
"save_credential_key",
"generate_and_save_credential_key",
"load_aden_api_key",
"save_aden_api_key",
"delete_aden_api_key",
# Validation
"ensure_credential_key_env",
"validate_agent_credentials",
+201
View File
@@ -0,0 +1,201 @@
"""
Dedicated file-based storage for bootstrap credentials.
HIVE_CREDENTIAL_KEY -> ~/.hive/secrets/credential_key (plain text, chmod 600)
ADEN_API_KEY -> ~/.hive/credentials/ (encrypted via EncryptedFileStorage)
Boot order:
1. load_credential_key() -- reads/generates the Fernet key, sets os.environ
2. load_aden_api_key() -- uses the encrypted store (which needs the key from step 1)
"""
from __future__ import annotations
import logging
import os
import stat
from pathlib import Path
logger = logging.getLogger(__name__)
CREDENTIAL_KEY_PATH = Path.home() / ".hive" / "secrets" / "credential_key"
CREDENTIAL_KEY_ENV_VAR = "HIVE_CREDENTIAL_KEY"
ADEN_CREDENTIAL_ID = "aden_api_key"
ADEN_ENV_VAR = "ADEN_API_KEY"
# ---------------------------------------------------------------------------
# HIVE_CREDENTIAL_KEY
# ---------------------------------------------------------------------------
def load_credential_key() -> str | None:
"""Load HIVE_CREDENTIAL_KEY with priority: env > file > shell config.
Sets ``os.environ["HIVE_CREDENTIAL_KEY"]`` as a side-effect when found.
Returns the key string, or ``None`` if unavailable everywhere.
"""
# 1. Already in environment (set by parent process, CI, Windows Registry, etc.)
key = os.environ.get(CREDENTIAL_KEY_ENV_VAR)
if key:
return key
# 2. Dedicated secrets file
key = _read_credential_key_file()
if key:
os.environ[CREDENTIAL_KEY_ENV_VAR] = key
return key
# 3. Shell config fallback (backward compat for old installs)
key = _read_from_shell_config(CREDENTIAL_KEY_ENV_VAR)
if key:
os.environ[CREDENTIAL_KEY_ENV_VAR] = key
return key
return None
def save_credential_key(key: str) -> Path:
"""Save HIVE_CREDENTIAL_KEY to ``~/.hive/secrets/credential_key``.
Creates parent dirs with mode 700, writes the file with mode 600.
Also sets ``os.environ["HIVE_CREDENTIAL_KEY"]``.
Returns:
The path that was written.
"""
path = CREDENTIAL_KEY_PATH
path.parent.mkdir(parents=True, exist_ok=True)
# Restrict the secrets directory itself
path.parent.chmod(stat.S_IRWXU) # 0o700
path.write_text(key)
path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
os.environ[CREDENTIAL_KEY_ENV_VAR] = key
return path
def generate_and_save_credential_key() -> str:
"""Generate a new Fernet key and persist it to ``~/.hive/secrets/credential_key``.
Returns:
The generated key string.
"""
from cryptography.fernet import Fernet
key = Fernet.generate_key().decode()
save_credential_key(key)
return key
# ---------------------------------------------------------------------------
# ADEN_API_KEY
# ---------------------------------------------------------------------------
def load_aden_api_key() -> str | None:
"""Load ADEN_API_KEY with priority: env > encrypted store > shell config.
**Must** be called after ``load_credential_key()`` because the encrypted
store depends on HIVE_CREDENTIAL_KEY.
Sets ``os.environ["ADEN_API_KEY"]`` as a side-effect when found.
Returns the key string, or ``None`` if unavailable everywhere.
"""
# 1. Already in environment
key = os.environ.get(ADEN_ENV_VAR)
if key:
return key
# 2. Encrypted credential store
key = _read_aden_from_encrypted_store()
if key:
os.environ[ADEN_ENV_VAR] = key
return key
# 3. Shell config fallback (backward compat)
key = _read_from_shell_config(ADEN_ENV_VAR)
if key:
os.environ[ADEN_ENV_VAR] = key
return key
return None
def save_aden_api_key(key: str) -> None:
"""Save ADEN_API_KEY to the encrypted credential store.
Also sets ``os.environ["ADEN_API_KEY"]``.
"""
from pydantic import SecretStr
from .models import CredentialKey, CredentialObject
from .storage import EncryptedFileStorage
storage = EncryptedFileStorage()
cred = CredentialObject(
id=ADEN_CREDENTIAL_ID,
keys={"api_key": CredentialKey(name="api_key", value=SecretStr(key))},
)
storage.save(cred)
os.environ[ADEN_ENV_VAR] = key
def delete_aden_api_key() -> None:
"""Remove ADEN_API_KEY from the encrypted store and ``os.environ``."""
try:
from .storage import EncryptedFileStorage
storage = EncryptedFileStorage()
storage.delete(ADEN_CREDENTIAL_ID)
except Exception:
logger.debug("Could not delete %s from encrypted store", ADEN_CREDENTIAL_ID)
os.environ.pop(ADEN_ENV_VAR, None)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _read_credential_key_file() -> str | None:
"""Read the credential key from ``~/.hive/secrets/credential_key``."""
try:
if CREDENTIAL_KEY_PATH.is_file():
value = CREDENTIAL_KEY_PATH.read_text().strip()
if value:
return value
except Exception:
logger.debug("Could not read %s", CREDENTIAL_KEY_PATH)
return None
def _read_from_shell_config(env_var: str) -> str | None:
"""Fallback: read an env var from ~/.zshrc or ~/.bashrc."""
try:
from aden_tools.credentials.shell_config import check_env_var_in_shell_config
found, value = check_env_var_in_shell_config(env_var)
if found and value:
return value
except ImportError:
pass
return None
def _read_aden_from_encrypted_store() -> str | None:
"""Try to load ADEN_API_KEY from the encrypted credential store."""
if not os.environ.get(CREDENTIAL_KEY_ENV_VAR):
return None
try:
from .storage import EncryptedFileStorage
storage = EncryptedFileStorage()
cred = storage.load(ADEN_CREDENTIAL_ID)
if cred:
return cred.get_key("api_key")
except Exception:
logger.debug("Could not load %s from encrypted store", ADEN_CREDENTIAL_ID)
return None
+10 -53
View File
@@ -256,57 +256,23 @@ class CredentialSetupSession:
def _ensure_credential_key(self) -> bool:
"""Ensure HIVE_CREDENTIAL_KEY is available for encrypted storage."""
if os.environ.get("HIVE_CREDENTIAL_KEY"):
from .key_storage import generate_and_save_credential_key, load_credential_key
if load_credential_key():
return True
# Try to load from shell config
try:
from aden_tools.credentials.shell_config import check_env_var_in_shell_config
found, value = check_env_var_in_shell_config("HIVE_CREDENTIAL_KEY")
if found and value:
os.environ["HIVE_CREDENTIAL_KEY"] = value
return True
except ImportError:
pass
# Generate a new key
self._print(f"{Colors.YELLOW}Initializing credential store...{Colors.NC}")
try:
from cryptography.fernet import Fernet
generated_key = Fernet.generate_key().decode()
os.environ["HIVE_CREDENTIAL_KEY"] = generated_key
# Save to shell config
self._save_key_to_shell_config(generated_key)
generate_and_save_credential_key()
self._print(
f"{Colors.GREEN}✓ Encryption key saved to ~/.hive/secrets/credential_key{Colors.NC}"
)
return True
except Exception as e:
self._print(f"{Colors.RED}Failed to initialize credential store: {e}{Colors.NC}")
return False
def _save_key_to_shell_config(self, key: str) -> None:
"""Save HIVE_CREDENTIAL_KEY to shell config."""
try:
from aden_tools.credentials.shell_config import (
add_env_var_to_shell_config,
)
success, config_path = add_env_var_to_shell_config(
"HIVE_CREDENTIAL_KEY",
key,
comment="Encryption key for Hive credential store",
)
if success:
self._print(f"{Colors.GREEN}✓ Encryption key saved to {config_path}{Colors.NC}")
except Exception:
# Fallback: just tell the user
self._print("\n")
self._print(
f"{Colors.YELLOW}Add this to your shell config (~/.zshrc or ~/.bashrc):{Colors.NC}"
)
self._print(f' export HIVE_CREDENTIAL_KEY="{key}"')
def _setup_single_credential(self, cred: MissingCredential) -> bool:
"""Set up a single credential. Returns True if configured."""
self._print(f"\n{Colors.CYAN}{'' * 60}{Colors.NC}")
@@ -444,19 +410,10 @@ class CredentialSetupSession:
self._print(f"{Colors.YELLOW}No key entered. Skipping.{Colors.NC}")
return False
os.environ["ADEN_API_KEY"] = aden_key
# Persist to encrypted store and set os.environ
from .key_storage import save_aden_api_key
# Save to shell config
try:
from aden_tools.credentials.shell_config import add_env_var_to_shell_config
add_env_var_to_shell_config(
"ADEN_API_KEY",
aden_key,
comment="Aden Platform API key",
)
except Exception:
pass
save_aden_api_key(aden_key)
# Sync from Aden
try:
+123 -38
View File
@@ -14,43 +14,46 @@ logger = logging.getLogger(__name__)
def ensure_credential_key_env() -> None:
"""Load credentials from shell config if not in environment.
"""Load bootstrap credentials into ``os.environ``.
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.
Priority chain for each credential:
1. ``os.environ`` (already set nothing to do)
2. Dedicated file storage (``~/.hive/secrets/`` or encrypted store)
3. Shell config fallback (``~/.zshrc`` / ``~/.bashrc``) for backward compat
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.)
Boot order matters: HIVE_CREDENTIAL_KEY must load BEFORE ADEN_API_KEY
because the encrypted store depends on it.
Remaining LLM/tool API keys still load from shell config.
"""
from .key_storage import load_aden_api_key, load_credential_key
# Step 1: HIVE_CREDENTIAL_KEY (must come first — encrypted store depends on it)
load_credential_key()
# Step 2: ADEN_API_KEY (uses encrypted store, then shell config fallback)
load_aden_api_key()
# Step 3: Load remaining LLM/tool API keys from shell config
try:
from aden_tools.credentials.shell_config import check_env_var_in_shell_config
except ImportError:
return
# 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)
var_name = spec.env_var
if var_name and var_name not in ("HIVE_CREDENTIAL_KEY", "ADEN_API_KEY"):
if not os.environ.get(var_name):
found, value = check_env_var_in_shell_config(var_name)
if found and value:
os.environ[var_name] = value
logger.debug("Loaded %s from shell config", var_name)
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)
if found and value:
os.environ[var_name] = value
logger.debug("Loaded %s from shell config", var_name)
@dataclass
class CredentialStatus:
@@ -71,6 +74,7 @@ class CredentialStatus:
direct_api_key_supported: bool
credential_key: str
aden_not_connected: bool # Aden-only cred, ADEN_API_KEY set, but integration missing
alternative_group: str | None = None # non-None when multiple providers can satisfy a tool
@dataclass
@@ -82,8 +86,34 @@ class CredentialValidationResult:
@property
def failed(self) -> list[CredentialStatus]:
"""Credentials that are missing, invalid, or Aden-not-connected."""
return [c for c in self.credentials if not c.available or c.valid is False]
"""Credentials that are missing, invalid, or Aden-not-connected.
For alternative groups (multi-provider tools like send_email), the group
is satisfied if ANY member is available and valid only report failures
when the entire group is unsatisfied.
"""
# Check which alternative groups are satisfied
alt_satisfied: dict[str, bool] = {}
for c in self.credentials:
if not c.alternative_group:
continue
if c.alternative_group not in alt_satisfied:
alt_satisfied[c.alternative_group] = False
if c.available and c.valid is not False:
alt_satisfied[c.alternative_group] = True
result = []
for c in self.credentials:
if c.alternative_group:
# Skip if any alternative in the group is satisfied
if alt_satisfied.get(c.alternative_group, False):
continue
if not c.available or c.valid is False:
result.append(c)
else:
if not c.available or c.valid is False:
result.append(c)
return result
@property
def has_errors(self) -> bool:
@@ -146,7 +176,7 @@ def _label(c: CredentialStatus) -> str:
return c.credential_name
def _presync_aden_tokens(credential_specs: dict) -> None:
def _presync_aden_tokens(credential_specs: dict, *, force: bool = False) -> None:
"""Sync Aden-backed OAuth tokens into env vars for validation.
When ADEN_API_KEY is available, fetches fresh OAuth tokens from the Aden
@@ -154,6 +184,11 @@ def _presync_aden_tokens(credential_specs: dict) -> None:
tokens instead of stale or mis-stored values in the encrypted store.
Only touches credentials that are ``aden_supported`` AND whose env var
is not already set (so explicit user exports always win).
Args:
force: When True, overwrite env vars that are already set. Used by
the credentials modal to pick up freshly reauthorized tokens
from Aden instead of reusing stale values from a prior sync.
"""
from framework.credentials.store import CredentialStore
@@ -166,7 +201,7 @@ def _presync_aden_tokens(credential_specs: dict) -> None:
for name, spec in credential_specs.items():
if not spec.aden_supported:
continue
if os.environ.get(spec.env_var):
if not force and os.environ.get(spec.env_var):
continue # Already set — don't overwrite
cred_id = spec.credential_id or name
# sync_all() already fetched everything available from Aden.
@@ -200,6 +235,7 @@ def validate_agent_credentials(
quiet: bool = False,
verify: bool = True,
raise_on_error: bool = True,
force_refresh: bool = False,
) -> CredentialValidationResult:
"""Check that required credentials are available and valid before running an agent.
@@ -214,6 +250,9 @@ def validate_agent_credentials(
verify: If True (default), run health checks on present credentials.
raise_on_error: If True (default), raise CredentialError when validation
fails. Set to False to get the result without raising.
force_refresh: If True, force re-sync of Aden OAuth tokens even when
env vars are already set. Used by the credentials modal after
reauthorization.
Returns:
CredentialValidationResult with status of ALL required credentials.
@@ -245,7 +284,7 @@ def validate_agent_credentials(
# into env vars so validation sees fresh tokens instead of stale values
# in the encrypted store (e.g., a previously mis-stored google.enc).
if os.environ.get("ADEN_API_KEY"):
_presync_aden_tokens(CREDENTIAL_SPECS)
_presync_aden_tokens(CREDENTIAL_SPECS, force=force_refresh)
env_mapping = {
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
@@ -257,12 +296,12 @@ def validate_agent_credentials(
storage = env_storage
store = CredentialStore(storage=storage)
# Build reverse mappings
tool_to_cred: dict[str, str] = {}
# Build reverse mappings — 1:many for multi-provider tools (e.g. send_email → resend OR google)
tool_to_creds: dict[str, list[str]] = {}
node_type_to_cred: dict[str, str] = {}
for cred_name, spec in CREDENTIAL_SPECS.items():
for tool_name in spec.tools:
tool_to_cred[tool_name] = cred_name
tool_to_creds.setdefault(tool_name, []).append(cred_name)
for nt in spec.node_types:
node_type_to_cred[nt] = cred_name
@@ -273,7 +312,11 @@ def validate_agent_credentials(
to_verify: list[int] = [] # indices into all_credentials
def _check_credential(
spec, cred_name: str, affected_tools: list[str], affected_node_types: list[str]
spec,
cred_name: str,
affected_tools: list[str],
affected_node_types: list[str],
alternative_group: str | None = None,
) -> None:
cred_id = spec.credential_id or cred_name
available = store.is_available(cred_id)
@@ -302,6 +345,7 @@ def validate_agent_credentials(
direct_api_key_supported=spec.direct_api_key_supported,
credential_key=spec.credential_key,
aden_not_connected=is_aden_nc,
alternative_group=alternative_group,
)
all_credentials.append(status)
@@ -310,15 +354,56 @@ def validate_agent_credentials(
# Check tool credentials
for tool_name in sorted(required_tools):
cred_name = tool_to_cred.get(tool_name)
if cred_name is None or cred_name in checked:
cred_names = tool_to_creds.get(tool_name)
if cred_names is None:
continue
checked.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
if not spec.required:
# Filter to credentials we haven't already checked
unchecked = [cn for cn in cred_names if cn not in checked]
if not unchecked:
continue
affected = sorted(t for t in required_tools if t in spec.tools)
_check_credential(spec, cred_name, affected_tools=affected, affected_node_types=[])
# Single provider — existing behavior
if len(unchecked) == 1:
cred_name = unchecked[0]
checked.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
if not spec.required:
continue
affected = sorted(t for t in required_tools if t in spec.tools)
_check_credential(spec, cred_name, affected_tools=affected, affected_node_types=[])
continue
# Multi-provider (e.g. send_email → resend OR google):
# satisfied if ANY provider credential is available.
available_cn = None
for cn in unchecked:
spec = CREDENTIAL_SPECS[cn]
cred_id = spec.credential_id or cn
if store.is_available(cred_id):
available_cn = cn
break
if available_cn is not None:
# Found an available provider — check (and health-check) it
checked.add(available_cn)
spec = CREDENTIAL_SPECS[available_cn]
affected = sorted(t for t in required_tools if t in spec.tools)
_check_credential(spec, available_cn, affected_tools=affected, affected_node_types=[])
else:
# None available — report ALL alternatives so the modal can show them
group_key = tool_name # e.g. "send_email"
for cn in unchecked:
checked.add(cn)
spec = CREDENTIAL_SPECS[cn]
affected = sorted(t for t in required_tools if t in spec.tools)
_check_credential(
spec,
cn,
affected_tools=affected,
affected_node_types=[],
alternative_group=group_key,
)
# Check node type credentials (e.g., ANTHROPIC_API_KEY for LLM nodes)
for nt in sorted(node_types):
+179 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, Protocol, runtime_checkable
@@ -90,15 +91,46 @@ class Message:
def _extract_spillover_filename(content: str) -> str | None:
"""Extract spillover filename from a truncated tool result.
"""Extract spillover filename from a tool result annotation.
Matches the pattern produced by EventLoopNode._truncate_tool_result():
"saved to 'tool_github_list_stargazers_abc123.txt'"
Matches patterns produced by EventLoopNode._truncate_tool_result():
- Large result: "saved to 'web_search_1.txt'"
- Small result: "[Saved to 'web_search_1.txt']"
"""
match = re.search(r"saved to '([^']+)'", content)
match = re.search(r"[Ss]aved to '([^']+)'", content)
return match.group(1) if match else None
_TC_ARG_LIMIT = 200 # max chars per tool_call argument after compaction
def _compact_tool_calls(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Truncate tool_call arguments to save context tokens during compaction.
Preserves ``id``, ``type``, and ``function.name`` exactly. Truncates
``function.arguments`` (a JSON string) to at most ``_TC_ARG_LIMIT`` chars
so that large payloads (e.g. set_output with full findings) don't survive
compaction and defeat the purpose of context reduction.
"""
compact = []
for tc in tool_calls:
func = tc.get("function", {})
args = func.get("arguments", "")
if len(args) > _TC_ARG_LIMIT:
args = args[:_TC_ARG_LIMIT] + "…[truncated]"
compact.append(
{
"id": tc.get("id", ""),
"type": tc.get("type", "function"),
"function": {
"name": func.get("name", ""),
"arguments": args,
},
}
)
return compact
# ---------------------------------------------------------------------------
# ConversationStore protocol (Phase 2)
# ---------------------------------------------------------------------------
@@ -353,12 +385,20 @@ class NodeConversation:
"""Best available token estimate.
Uses actual API input token count when available (set via
:meth:`update_token_count`), otherwise falls back to the rough
``total_chars / 4`` heuristic.
:meth:`update_token_count`), otherwise falls back to a
``total_chars / 4`` heuristic that includes both message content
AND tool_call argument sizes.
"""
if self._last_api_input_tokens is not None:
return self._last_api_input_tokens
total_chars = sum(len(m.content) for m in self._messages)
total_chars = 0
for m in self._messages:
total_chars += len(m.content)
if m.tool_calls:
for tc in m.tool_calls:
func = tc.get("function", {})
total_chars += len(func.get("arguments", ""))
total_chars += len(func.get("name", ""))
return total_chars // 4
def update_token_count(self, actual_input_tokens: int) -> None:
@@ -587,6 +627,138 @@ class NodeConversation:
self._messages = [summary_msg] + recent_messages
self._last_api_input_tokens = None # reset; next LLM call will recalibrate
async def compact_preserving_structure(
self,
spillover_dir: str,
keep_recent: int = 4,
phase_graduated: bool = False,
) -> None:
"""Structure-preserving compaction: save freeform text to file, keep tool messages.
Unlike ``compact()`` which replaces ALL old messages with a single LLM
summary, this method preserves the tool call structure (assistant
messages with tool_calls + tool result messages) that are already tiny
after pruning. Only freeform text exchanges (user messages,
text-only assistant messages) are saved to a file and removed.
The result: the agent retains exact knowledge of what tools it called,
where each result is stored, and can load the conversation text if
needed. No LLM summary call. No heuristics. Nothing lost.
"""
if not self._messages:
return
total = len(self._messages)
# Determine split point (same logic as compact)
if phase_graduated and self._current_phase:
split = self._find_phase_graduated_split()
else:
split = None
if split is None:
keep_recent = max(0, min(keep_recent, total - 1))
split = total - keep_recent if keep_recent > 0 else total
# Advance split past orphaned tool results at the boundary
while split < total and self._messages[split].role == "tool":
split += 1
if split == 0:
return
old_messages = self._messages[:split]
# Classify old messages: structural (keep) vs freeform (save to file)
kept_structural: list[Message] = []
freeform_lines: list[str] = []
for msg in old_messages:
if msg.role == "tool":
# Tool results — already pruned to ~30 tokens (file reference).
# Keep in conversation.
kept_structural.append(msg)
elif msg.role == "assistant" and msg.tool_calls:
# Assistant message with tool_calls — keep the tool_calls
# with truncated arguments, clear the freeform text content.
compact_tcs = _compact_tool_calls(msg.tool_calls)
kept_structural.append(
Message(
seq=msg.seq,
role=msg.role,
content="",
tool_calls=compact_tcs,
is_error=msg.is_error,
phase_id=msg.phase_id,
is_transition_marker=msg.is_transition_marker,
)
)
else:
# Freeform text (user messages, text-only assistant messages)
# — save to file and remove from conversation.
role_label = msg.role
text = msg.content
if len(text) > 2000:
text = text[:2000] + ""
freeform_lines.append(f"[{role_label}] (seq={msg.seq}): {text}")
# Write freeform text to a numbered conversation file
spill_path = Path(spillover_dir)
spill_path.mkdir(parents=True, exist_ok=True)
# Find next conversation file number
existing = sorted(spill_path.glob("conversation_*.md"))
next_n = len(existing) + 1
conv_filename = f"conversation_{next_n}.md"
if freeform_lines:
header = f"## Compacted conversation (messages 1-{split})\n\n"
conv_text = header + "\n\n".join(freeform_lines)
(spill_path / conv_filename).write_text(conv_text, encoding="utf-8")
else:
# Nothing to save — skip file creation
conv_filename = ""
# Build reference message
if conv_filename:
ref_content = (
f"[Previous conversation saved to '{conv_filename}'. "
f"Use load_data('{conv_filename}') to review if needed.]"
)
else:
ref_content = "[Previous freeform messages compacted.]"
# Use a seq just before the first kept message
recent_messages = list(self._messages[split:])
if kept_structural:
ref_seq = kept_structural[0].seq - 1
elif recent_messages:
ref_seq = recent_messages[0].seq - 1
else:
ref_seq = self._next_seq
self._next_seq += 1
ref_msg = Message(seq=ref_seq, role="user", content=ref_content)
# Persist: delete old messages from store, write reference + kept structural
if self._store:
first_kept_seq = (
kept_structural[0].seq
if kept_structural
else (recent_messages[0].seq if recent_messages else self._next_seq)
)
# Delete everything before the first structural message we're keeping
await self._store.delete_parts_before(first_kept_seq)
# Write the reference message
await self._store.write_part(ref_msg.seq, ref_msg.to_storage_dict())
# Write kept structural messages (they may have been modified)
for msg in kept_structural:
await self._store.write_part(msg.seq, msg.to_storage_dict())
await self._store.write_cursor({"next_seq": self._next_seq})
# Reassemble: reference + kept structural (in original order) + recent
self._messages = [ref_msg] + kept_structural + recent_messages
self._last_api_input_tokens = None
def _find_phase_graduated_split(self) -> int | None:
"""Find split point that preserves current + previous phase.
+39
View File
@@ -503,6 +503,45 @@ class GraphSpec(BaseModel):
"""Get all edges entering a node."""
return [e for e in self.edges if e.target == node_id]
def build_capability_summary(self, from_node_id: str) -> str:
"""Build a summary of the agent's downstream workflow phases and tools.
Walks the graph from *from_node_id* and collects all reachable nodes
(excluding the starting node itself) so that client-facing entry nodes
can inform the user about what the overall agent is capable of.
Returns:
A formatted string listing each downstream node's name,
description, and tools or an empty string when there are
no downstream nodes.
"""
reachable: list[Any] = []
visited: set[str] = set()
queue = [from_node_id]
while queue:
nid = queue.pop()
if nid in visited:
continue
visited.add(nid)
node = self.get_node(nid)
if node and nid != from_node_id:
reachable.append(node)
for edge in self.get_outgoing_edges(nid):
queue.append(edge.target)
if not reachable:
return ""
lines = [
"## Agent Capabilities",
"This agent has the following workflow phases and tools:",
]
for node in reachable:
tool_str = f" (tools: {', '.join(node.tools)})" if node.tools else ""
lines.append(f"- {node.name}: {node.description}{tool_str}")
return "\n".join(lines)
def detect_fan_out_nodes(self) -> dict[str, list[str]]:
"""
Detect nodes that fan-out to multiple targets.
+328 -117
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import re
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
@@ -74,7 +75,7 @@ class LoopConfig:
"""Configuration for the event loop."""
max_iterations: int = 50
max_tool_calls_per_turn: int = 10
max_tool_calls_per_turn: int = 30
judge_every_n_turns: int = 1
stall_detection_threshold: int = 3
max_history_tokens: int = 32_000
@@ -91,7 +92,7 @@ class LoopConfig:
# written to a file and the truncated message includes the filename so
# the agent can retrieve it with load_data(). If *spillover_dir* is
# ``None`` the result is simply truncated with an explanatory note.
max_tool_result_chars: int = 3_000
max_tool_result_chars: int = 30_000
spillover_dir: str | None = None # Path string; created on first use
# --- Stream retry (transient error recovery within EventLoopNode) ---
@@ -107,6 +108,14 @@ class LoopConfig:
# N consecutive turns. For client-facing nodes, blocks for user input.
# For non-client-facing nodes, injects a warning into the conversation.
tool_doom_loop_threshold: int = 3
# --- Client-facing auto-block grace period ---
# When a client-facing node produces text-only turns (no tools, no
# set_output), the judge is skipped for this many consecutive auto-block
# turns. After the grace period, the judge runs to apply RETRY pressure
# on models stuck in a clarification loop. Explicit ask_user() calls
# always skip the judge regardless of this setting.
cf_grace_turns: int = 1
tool_doom_loop_enabled: bool = True
@@ -216,6 +225,8 @@ class EventLoopNode(NodeProtocol):
self._stream_task: asyncio.Task | None = None
# Track which nodes already have an action plan emitted (skip on revisit)
self._action_plan_emitted: set[str] = set()
# Monotonic counter for spillover file naming (web_search_1.txt, etc.)
self._spill_counter: int = 0
def validate_input(self, ctx: NodeContext) -> list[str]:
"""Validate hard requirements only.
@@ -245,6 +256,10 @@ class EventLoopNode(NodeProtocol):
# Verdict counters for runtime logging
_accept_count = _retry_count = _escalate_count = _continue_count = 0
# Client-facing auto-block grace: consecutive text-only turns without
# any real tool call or set_output. Resets on progress.
_cf_text_only_streak = 0
# 1. Guard: LLM required
if ctx.llm is None:
error_msg = "LLM provider not available"
@@ -365,6 +380,9 @@ class EventLoopNode(NodeProtocol):
if initial_message:
await conversation.add_user_message(initial_message)
# 2b. Restore spill counter from existing files (resume safety)
self._restore_spill_counter()
# 3. Build tool list: node tools + synthetic set_output + ask_user tools
tools = list(ctx.available_tools)
set_output_tool = self._build_set_output_tool(ctx.node_spec.output_keys)
@@ -403,10 +421,6 @@ class EventLoopNode(NodeProtocol):
recent_responses: list[str] = _restored_recent_responses
recent_tool_fingerprints: list[list[tuple[str, str]]] = _restored_tool_fingerprints
# 5b. Client-facing state: after user responds, expect the LLM to
# work (call tools) rather than auto-blocking again on text-only.
_cf_expecting_work = False
# 6. Main loop
for iteration in range(start_iteration, self._config.max_iterations):
iter_start = time.time()
@@ -537,7 +551,36 @@ class EventLoopNode(NodeProtocol):
await asyncio.sleep(delay)
continue # retry same iteration
# Non-transient or retries exhausted — existing crash handler
# Non-transient or retries exhausted.
# For client-facing nodes, surface the error and wait
# for user input instead of killing the loop. The user
# can retry or adjust the request.
if ctx.node_spec.client_facing:
error_msg = f"LLM call failed: {e}"
logger.error(
"[%s] iter=%d: %s — waiting for user input",
node_id,
iteration,
error_msg,
)
if self._event_bus:
await self._event_bus.emit_node_retry(
stream_id=stream_id,
node_id=node_id,
retry_count=_stream_retry_count,
max_retries=self._config.max_stream_retries,
error=str(e)[:500],
execution_id=execution_id,
)
# Inject the error as an assistant message so the
# user sees it, then block for their next message.
await conversation.add_assistant_message(
f"[Error: {error_msg}. Please try again.]"
)
await self._await_user_input(ctx, prompt="")
break # exit retry loop, continue outer iteration
# Non-client-facing: crash as before
import traceback
iter_latency_ms = int((time.time() - iter_start) * 1000)
@@ -594,6 +637,10 @@ class EventLoopNode(NodeProtocol):
if conversation.needs_compaction():
await self._compact_tiered(ctx, conversation, accumulator)
# Reset auto-block grace streak when real work happens
if real_tool_results or outputs_set:
_cf_text_only_streak = 0
# 6e'''. Empty response guard — if the LLM returned nothing
# (no text, no real tools, no set_output) and all required
# outputs are already set, accept immediately. This prevents
@@ -732,25 +779,17 @@ class EventLoopNode(NodeProtocol):
recent_tool_fingerprints=recent_tool_fingerprints,
)
# 6h. Client-facing state transition: tool calls mean the LLM
# acted on user input, so the next text-only turn is a new
# presentation (auto-block is appropriate again).
if real_tool_results or outputs_set:
_cf_expecting_work = False
# 6h'. Client-facing input blocking
#
# Two triggers:
# (a) Explicit ask_user() — always blocks, then falls through
# to judge evaluation (6i).
# (a) Explicit ask_user() — blocks, then skips judge (6i).
# The LLM intentionally asked a question; judging before the
# user answers would inject confusing "missing outputs"
# feedback.
# (b) Auto-block — a text-only turn (no real tools, no
# set_output) from a client-facing node is addressed to the
# user. Block for their response, then *skip* judge so the
# next LLM turn can process the reply without confusing
# "missing outputs" feedback.
# However, if the user already provided input and the LLM
# responds with text-only instead of calling tools, fall
# through to judge so weak models get RETRY feedback.
# set_output) from a client-facing node. Blocks for the
# user's response, then falls through to judge so models
# stuck in a clarification loop get RETRY feedback.
#
# Turns that include tool calls or set_output are *work*, not
# conversation — they flow through without blocking.
@@ -762,19 +801,10 @@ class EventLoopNode(NodeProtocol):
_cf_block = True
_cf_prompt = ask_user_prompt
elif assistant_text and not real_tool_results and not outputs_set:
_missing = self._get_missing_output_keys(
accumulator,
ctx.node_spec.output_keys,
ctx.node_spec.nullable_output_keys,
)
if _cf_expecting_work and _missing:
# User already responded and required outputs are
# still missing — LLM should be working, not
# talking. Fall through to judge (6i).
pass
else:
_cf_block = True
_cf_auto = True
# Text-only response from client-facing node — this is
# addressed to the user. Always block for their reply.
_cf_block = True
_cf_auto = True
if _cf_block:
if self._shutdown:
@@ -831,8 +861,6 @@ class EventLoopNode(NodeProtocol):
ctx, prompt=_cf_prompt, skip_emit=user_input_requested
)
logger.info("[%s] iter=%d: unblocked, got_input=%s", node_id, iteration, got_input)
if got_input:
_cf_expecting_work = True
if not got_input:
await self._publish_loop_completed(
stream_id, node_id, iteration + 1, execution_id
@@ -879,33 +907,73 @@ class EventLoopNode(NodeProtocol):
recent_responses.clear()
# Skip judge after blocking for user input — both auto-block
# and explicit ask_user. The user's message sits in the
# injection queue and won't be drained until step 6b of the
# next iteration. If we let the judge fire now it sees
# "missing outputs" and injects RETRY feedback *before* the
# user's answer, confusing the LLM.
# _continue_count += 1
# if ctx.runtime_logger:
# iter_latency_ms = int((time.time() - iter_start) * 1000)
# verdict_fb = (
# "Auto-blocked for user input (pre-interaction)"
# if _cf_auto
# else "Blocked for ask_user input (skip judge)"
# )
# ctx.runtime_logger.log_step(
# node_id=node_id,
# node_type="event_loop",
# step_index=iteration,
# verdict="CONTINUE",
# verdict_feedback=verdict_fb,
# tool_calls=logged_tool_calls,
# llm_text=assistant_text,
# input_tokens=turn_tokens.get("input", 0),
# output_tokens=turn_tokens.get("output", 0),
# latency_ms=iter_latency_ms,
# )
# continue
# -- Judge-skip decision after client-facing blocking --
#
# Explicit ask_user: skip judge while the agent is still
# gathering information from the user. BUT if all required
# outputs have already been set, don't skip — fall through to
# the judge so it can accept the completed node.
#
# Auto-block (text-only, no tools): skip judge within a
# grace period of N consecutive text-only turns. Normal
# conversations are 1-3 exchanges before set_output.
# After the grace period, fall through to judge so models
# stuck in a clarification loop get RETRY pressure.
if not _cf_auto:
# Explicit ask_user: skip judge only if outputs are incomplete
_missing = (
self._get_missing_output_keys(
accumulator,
ctx.node_spec.output_keys,
ctx.node_spec.nullable_output_keys,
)
if accumulator is not None
else True
)
_outputs_complete = not _missing
if not _outputs_complete:
_cf_text_only_streak = 0
_continue_count += 1
if ctx.runtime_logger:
iter_latency_ms = int((time.time() - iter_start) * 1000)
ctx.runtime_logger.log_step(
node_id=node_id,
node_type="event_loop",
step_index=iteration,
verdict="CONTINUE",
verdict_feedback="Blocked for ask_user input (skip judge)",
tool_calls=logged_tool_calls,
llm_text=assistant_text,
input_tokens=turn_tokens.get("input", 0),
output_tokens=turn_tokens.get("output", 0),
latency_ms=iter_latency_ms,
)
continue
# All outputs set — fall through to judge for acceptance
# Auto-block: apply grace period
_cf_text_only_streak += 1
if _cf_text_only_streak <= self._config.cf_grace_turns:
_continue_count += 1
if ctx.runtime_logger:
iter_latency_ms = int((time.time() - iter_start) * 1000)
ctx.runtime_logger.log_step(
node_id=node_id,
node_type="event_loop",
step_index=iteration,
verdict="CONTINUE",
verdict_feedback=(
f"Auto-block grace ({_cf_text_only_streak}"
f"/{self._config.cf_grace_turns})"
),
tool_calls=logged_tool_calls,
llm_text=assistant_text,
input_tokens=turn_tokens.get("input", 0),
output_tokens=turn_tokens.get("output", 0),
latency_ms=iter_latency_ms,
)
continue
# Beyond grace period — fall through to judge (6i)
# 6i. Judge evaluation
should_judge = (
@@ -981,7 +1049,6 @@ class EventLoopNode(NodeProtocol):
)
await conversation.add_user_message(hint)
# Gap D: log ACCEPT-with-missing-keys as RETRY
_cf_expecting_work = True
_retry_count += 1
if ctx.runtime_logger:
iter_latency_ms = int((time.time() - iter_start) * 1000)
@@ -1091,7 +1158,6 @@ class EventLoopNode(NodeProtocol):
)
elif verdict.action == "RETRY":
_cf_expecting_work = True
_retry_count += 1
if ctx.runtime_logger:
iter_latency_ms = int((time.time() - iter_start) * 1000)
@@ -1386,10 +1452,15 @@ class EventLoopNode(NodeProtocol):
}
for tc in tool_calls
]
await conversation.add_assistant_message(
content=accumulated_text,
tool_calls=tc_dicts,
)
# Skip storing empty turns — no content, no tool calls.
# An empty assistant message (e.g. Codex returning nothing after
# a tool result) confuses some models on the next turn and causes
# cascading empty-stream failures.
if accumulated_text or tc_dicts:
await conversation.add_assistant_message(
content=accumulated_text,
tool_calls=tc_dicts,
)
# If no tool calls, turn is complete
if not tool_calls:
@@ -1461,6 +1532,7 @@ class EventLoopNode(NodeProtocol):
pass
key = tc.tool_input.get("key", "")
await accumulator.set(key, value)
self._record_learning(key, value)
outputs_set_this_turn.append(key)
await self._publish_output_key_set(stream_id, node_id, key, execution_id)
logged_tool_calls.append(
@@ -1889,8 +1961,19 @@ class EventLoopNode(NodeProtocol):
# Client-facing nodes with no output keys are meant for
# continuous interaction — they should not auto-accept.
# Only exit via shutdown, max_iterations, or max_node_visits.
# Inject tool-use pressure so models stuck in a
# "narrate-instead-of-act" loop get corrective feedback.
if not output_keys and ctx.node_spec.client_facing:
return JudgeVerdict(action="RETRY", feedback="")
return JudgeVerdict(
action="RETRY",
feedback=(
"STOP describing what you will do. "
"You have FULL access to all tools — file creation, "
"shell commands, MCP tools — and you CAN call them "
"directly in your response. Respond ONLY with tool "
"calls, no prose. Execute the task now."
),
)
# Level 2: conversation-aware quality check (if success_criteria set)
if ctx.node_spec.success_criteria and ctx.llm:
@@ -2164,27 +2247,102 @@ class EventLoopNode(NodeProtocol):
result = await result
return result
def _record_learning(self, key: str, value: Any) -> None:
"""Append a set_output value to adapt.md as a learning entry.
Called at set_output time the moment knowledge is produced so that
adapt.md accumulates the agent's outputs across the session. Since
adapt.md is injected into the system prompt, these persist through
any compaction.
"""
if not self._config.spillover_dir:
return
try:
adapt_path = Path(self._config.spillover_dir) / "adapt.md"
content = adapt_path.read_text(encoding="utf-8") if adapt_path.exists() else ""
if "## Outputs" not in content:
content += "\n\n## Outputs\n"
# Truncate long values for memory (full value is in shared memory)
v_str = str(value)
if len(v_str) > 500:
v_str = v_str[:500] + ""
entry = f"- {key}: {v_str}\n"
# Replace existing entry for same key (update, not duplicate)
lines = content.splitlines(keepends=True)
replaced = False
for i, line in enumerate(lines):
if line.startswith(f"- {key}:"):
lines[i] = entry
replaced = True
break
if replaced:
content = "".join(lines)
else:
content += entry
adapt_path.write_text(content, encoding="utf-8")
except Exception as e:
logger.warning("Failed to record learning for key=%s: %s", key, e)
def _next_spill_filename(self, tool_name: str) -> str:
"""Return a short, monotonic filename for a tool result spill."""
self._spill_counter += 1
# Shorten common tool name prefixes to save tokens
short = tool_name.removeprefix("tool_").removeprefix("mcp_")
return f"{short}_{self._spill_counter}.txt"
def _restore_spill_counter(self) -> None:
"""Scan spillover_dir for existing spill files and restore the counter."""
spill_dir = self._config.spillover_dir
if not spill_dir:
return
spill_path = Path(spill_dir)
if not spill_path.is_dir():
return
max_n = 0
for f in spill_path.iterdir():
if not f.is_file():
continue
m = re.search(r"_(\d+)\.txt$", f.name)
if m:
max_n = max(max_n, int(m.group(1)))
if max_n > self._spill_counter:
self._spill_counter = max_n
logger.info("Restored spill counter to %d from existing files", max_n)
def _truncate_tool_result(
self,
result: ToolResult,
tool_name: str,
) -> ToolResult:
"""Truncate a large tool result to keep the conversation context small.
"""Persist tool result to file and optionally truncate for context.
If *spillover_dir* is configured and the result exceeds
*max_tool_result_chars*, the full content is written to a file and
the in-context result is replaced with a preview + filename reference.
Without *spillover_dir*, large results are truncated with a note.
When *spillover_dir* is configured, EVERY non-error tool result is
saved to a file (short filename like ``web_search_1.txt``). A
``[Saved to '...']`` annotation is appended so the reference
survives pruning and compaction.
Small results (and errors) pass through unchanged.
- Small results ( limit): full content kept + file annotation
- Large results (> limit): preview + file reference
- Errors: pass through unchanged
- load_data results: truncate with pagination hint (no re-spill)
"""
limit = self._config.max_tool_result_chars
if limit <= 0 or result.is_error or len(result.content) <= limit:
# Errors always pass through unchanged
if result.is_error:
return result
# load_data is the designated mechanism for reading spilled files.
# Don't re-spill (circular), but DO truncate with a pagination hint.
# load_data reads FROM spilled files — never re-spill (circular).
# Just truncate with a pagination hint if the result is too large.
if tool_name == "load_data":
if limit <= 0 or len(result.content) <= limit:
return result # Small load_data result — pass through as-is
# Large load_data result — truncate with pagination hint
preview_chars = max(limit - 300, limit // 2)
preview = result.content[:preview_chars]
truncated = (
@@ -2206,21 +2364,14 @@ class EventLoopNode(NodeProtocol):
is_error=False,
)
# Determine a preview size — leave room for the metadata wrapper
preview_chars = max(limit - 300, limit // 2)
preview = result.content[:preview_chars]
spill_dir = self._config.spillover_dir
if spill_dir:
spill_path = Path(spill_dir)
spill_path.mkdir(parents=True, exist_ok=True)
# Use tool_use_id for uniqueness, sanitise for filesystem
safe_id = result.tool_use_id.replace("/", "_")[:60]
filename = f"tool_{tool_name}_{safe_id}.txt"
filename = self._next_spill_filename(tool_name)
# Pretty-print JSON content so load_data's line-based
# pagination works correctly. Compact JSON (no newlines)
# would produce a single line that defeats pagination.
# pagination works correctly.
write_content = result.content
try:
parsed = json.loads(result.content)
@@ -2230,20 +2381,43 @@ class EventLoopNode(NodeProtocol):
(spill_path / filename).write_text(write_content, encoding="utf-8")
truncated = (
f"[Result from {tool_name}: {len(result.content)} chars — "
f"too large for context, saved to '{filename}'. "
f"Use load_data(filename='{filename}') "
f"to read the full result.]\n\n"
f"Preview:\n{preview}"
if limit > 0 and len(result.content) > limit:
# Large result: preview + file reference
preview_chars = max(limit - 300, limit // 2)
preview = result.content[:preview_chars]
content = (
f"[Result from {tool_name}: {len(result.content)} chars — "
f"too large for context, saved to '{filename}'. "
f"Use load_data(filename='{filename}') "
f"to read the full result.]\n\n"
f"Preview:\n{preview}"
)
logger.info(
"Tool result spilled to file: %s (%d chars → %s)",
tool_name,
len(result.content),
filename,
)
else:
# Small result: keep full content + annotation
content = f"{result.content}\n\n[Saved to '{filename}']"
logger.info(
"Tool result saved to file: %s (%d chars → %s)",
tool_name,
len(result.content),
filename,
)
return ToolResult(
tool_use_id=result.tool_use_id,
content=content,
is_error=False,
)
logger.info(
"Tool result spilled to file: %s (%d chars → %s)",
tool_name,
len(result.content),
filename,
)
else:
# No spillover_dir — truncate in-place if needed
if limit > 0 and len(result.content) > limit:
preview_chars = max(limit - 300, limit // 2)
preview = result.content[:preview_chars]
truncated = (
f"[Result from {tool_name}: {len(result.content)} chars — "
f"truncated to fit context budget. Only the first "
@@ -2255,12 +2429,13 @@ class EventLoopNode(NodeProtocol):
len(result.content),
len(truncated),
)
return ToolResult(
tool_use_id=result.tool_use_id,
content=truncated,
is_error=False,
)
return ToolResult(
tool_use_id=result.tool_use_id,
content=truncated,
is_error=False,
)
return result
async def _compact_tiered(
self,
@@ -2329,18 +2504,44 @@ class EventLoopNode(NodeProtocol):
if ratio >= 1.2:
level = "emergency"
keep = 1
logger.warning("Emergency compaction triggered (usage %.0f%%)", ratio * 100)
summary = self._build_emergency_summary(ctx, accumulator, conversation)
await conversation.compact(summary, keep_recent=1, phase_graduated=_phase_grad)
elif ratio >= 1.0:
level = "aggressive"
keep = 2
logger.info("Aggressive compaction triggered (usage %.0f%%)", ratio * 100)
summary = await self._generate_compaction_summary(ctx, conversation)
await conversation.compact(summary, keep_recent=2, phase_graduated=_phase_grad)
else:
level = "normal"
summary = await self._generate_compaction_summary(ctx, conversation)
await conversation.compact(summary, keep_recent=4, phase_graduated=_phase_grad)
keep = 4
spill_dir = self._config.spillover_dir
if spill_dir:
# Structure-preserving: save freeform text to file, keep tool messages
await conversation.compact_preserving_structure(
spillover_dir=spill_dir,
keep_recent=keep,
phase_graduated=_phase_grad,
)
# Circuit breaker: if structure-preserving compaction barely helped
# (still over budget), fall back to destructive compact() which
# replaces everything with a summary.
mid_ratio = conversation.usage_ratio()
if mid_ratio >= 0.9 * ratio:
logger.warning(
"Structure-preserving compaction ineffective "
"(%.0f%% -> %.0f%%), falling back to summary compaction",
ratio * 100,
mid_ratio * 100,
)
summary = self._build_emergency_summary(ctx, accumulator, conversation)
await conversation.compact(summary, keep_recent=keep, phase_graduated=_phase_grad)
else:
# Fallback: LLM-based summary (no spillover dir available)
if level == "emergency":
summary = self._build_emergency_summary(ctx, accumulator, conversation)
else:
summary = await self._generate_compaction_summary(ctx, conversation)
await conversation.compact(summary, keep_recent=keep, phase_graduated=_phase_grad)
new_ratio = conversation.usage_ratio()
logger.info(
@@ -2502,13 +2703,23 @@ class EventLoopNode(NodeProtocol):
if adapt_text:
parts.append(f"AGENT MEMORY (adapt.md):\n{adapt_text}")
files = sorted(
all_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])
# Separate conversation history files from regular data files
conv_files = [f for f in all_files if re.match(r"conversation_\d+\.md$", f)]
data_files = [f for f in all_files if f not in conv_files]
if conv_files:
conv_list = "\n".join(f" - {f}" for f in conv_files)
parts.append(
"CONVERSATION HISTORY (freeform messages saved during compaction — "
"use load_data to review earlier dialogue):\n" + conv_list
)
if data_files:
file_list = "\n".join(f" - {f}" for f in data_files[:30])
parts.append("DATA FILES (use load_data to read):\n" + file_list)
else:
if not all_files:
parts.append(
"NOTE: Large tool results may have been saved to files. "
"Use list_data_files() to check."
+58 -13
View File
@@ -731,6 +731,7 @@ class GraphExecutor:
event_triggered=_event_triggered,
identity_prompt=getattr(graph, "identity_prompt", ""),
narrative=_resume_narrative,
graph=graph,
)
# Log actual input data being read
@@ -1287,19 +1288,48 @@ class GraphExecutor:
protect_tokens=2000,
)
if continuous_conversation.needs_compaction():
_phase_ratio = continuous_conversation.usage_ratio()
self.logger.info(
" Phase-boundary compaction (%.0f%% usage)",
continuous_conversation.usage_ratio() * 100,
_phase_ratio * 100,
)
summary = (
f"Summary of earlier phases (before {next_spec.name}). "
"See transition markers for phase details."
)
await continuous_conversation.compact(
summary,
keep_recent=4,
phase_graduated=True,
_data_dir = (
str(self._storage_path / "data") if self._storage_path else None
)
if _data_dir:
await continuous_conversation.compact_preserving_structure(
spillover_dir=_data_dir,
keep_recent=4,
phase_graduated=True,
)
# Circuit breaker: if still over budget, fall back
_post_ratio = continuous_conversation.usage_ratio()
if _post_ratio >= 0.9 * _phase_ratio:
self.logger.warning(
" Structure-preserving compaction ineffective "
"(%.0f%% -> %.0f%%), falling back to summary",
_phase_ratio * 100,
_post_ratio * 100,
)
summary = (
f"Summary of earlier phases (before {next_spec.name}). "
"See transition markers for phase details."
)
await continuous_conversation.compact(
summary,
keep_recent=4,
phase_graduated=True,
)
else:
summary = (
f"Summary of earlier phases (before {next_spec.name}). "
"See transition markers for phase details."
)
await continuous_conversation.compact(
summary,
keep_recent=4,
phase_graduated=True,
)
# Update input_data for next node
input_data = result.output
@@ -1553,6 +1583,7 @@ class GraphExecutor:
event_triggered: bool = False,
identity_prompt: str = "",
narrative: str = "",
graph: "GraphSpec | None" = None,
) -> NodeContext:
"""Build execution context for a node."""
# Filter tools to those available to this node
@@ -1581,6 +1612,18 @@ class GraphExecutor:
node_tool_names=node_spec.tools,
)
# Build goal context, enriched with capability summary for
# client-facing nodes so the LLM knows what the full agent can do.
goal_context = goal.to_prompt_context()
if graph and node_spec.client_facing:
capability_summary = graph.build_capability_summary(graph.entry_node)
if capability_summary:
goal_context = (
f"{goal_context}\n\n{capability_summary}"
if goal_context
else capability_summary
)
return NodeContext(
runtime=self.runtime,
node_id=node_spec.id,
@@ -1589,7 +1632,7 @@ class GraphExecutor:
input_data=input_data,
llm=self.llm,
available_tools=available_tools,
goal_context=goal.to_prompt_context(),
goal_context=goal_context,
goal=goal, # Pass Goal object for LLM-powered routers
max_tokens=max_tokens,
runtime_logger=self.runtime_logger,
@@ -1672,11 +1715,11 @@ class GraphExecutor:
judge=None, # implicit judge: accept when output_keys are filled
config=LoopConfig(
max_iterations=lc.get("max_iterations", default_max_iter),
max_tool_calls_per_turn=lc.get("max_tool_calls_per_turn", 10),
max_tool_calls_per_turn=lc.get("max_tool_calls_per_turn", 30),
tool_call_overflow_margin=lc.get("tool_call_overflow_margin", 0.5),
stall_detection_threshold=lc.get("stall_detection_threshold", 3),
max_history_tokens=lc.get("max_history_tokens", 32000),
max_tool_result_chars=lc.get("max_tool_result_chars", 3_000),
max_tool_result_chars=lc.get("max_tool_result_chars", 30_000),
spillover_dir=spillover,
),
tool_executor=self.tool_executor,
@@ -1956,7 +1999,9 @@ class GraphExecutor:
branch.retry_count = attempt
# Build context for this branch
ctx = self._build_context(node_spec, memory, goal, mapped, graph.max_tokens)
ctx = self._build_context(
node_spec, memory, goal, mapped, graph.max_tokens, graph=graph
)
node_impl = self._get_node_implementation(node_spec, graph.cleanup_llm_model)
# Emit node-started event (skip event_loop nodes)
+48 -23
View File
@@ -118,6 +118,11 @@ RATE_LIMIT_MAX_RETRIES = 10
RATE_LIMIT_BACKOFF_BASE = 2 # seconds
RATE_LIMIT_MAX_DELAY = 120 # seconds - cap to prevent absurd waits
# Empty-stream retries use a short fixed delay, not the rate-limit backoff.
# Conversation-structure issues are deterministic — long waits don't help.
EMPTY_STREAM_MAX_RETRIES = 3
EMPTY_STREAM_RETRY_DELAY = 1.0 # seconds
# Directory for dumping failed requests
FAILED_REQUESTS_DIR = Path.home() / ".hive" / "failed_requests"
@@ -770,6 +775,18 @@ class LiteLLMProvider(LLMProvider):
else:
full_messages.insert(0, {"role": "system", "content": json_instruction.strip()})
# Remove ghost empty assistant messages (content="" and no tool_calls).
# These arise when a model returns an empty stream after a tool result
# (an "expected" no-op turn). Keeping them in history confuses some
# models (notably Codex/gpt-5.3) and causes cascading empty streams.
full_messages = [
m
for m in full_messages
if not (
m.get("role") == "assistant" and not m.get("content") and not m.get("tool_calls")
)
]
kwargs: dict[str, Any] = {
"model": self.model,
"messages": full_messages,
@@ -899,7 +916,7 @@ class LiteLLMProvider(LLMProvider):
# (If text deltas were yielded above, has_content is True
# and we skip the retry path — nothing was yielded in vain.)
has_content = accumulated_text or tool_calls_acc
if not has_content and attempt < RATE_LIMIT_MAX_RETRIES:
if not has_content:
# If the conversation ends with an assistant or tool
# message, an empty stream is expected — the LLM has
# nothing new to say. Don't burn retries on this;
@@ -912,8 +929,12 @@ class LiteLLMProvider(LLMProvider):
None,
)
if last_role in ("assistant", "tool"):
logger.debug(
"[stream] Empty response after %s message — expected, not retrying.",
logger.warning(
"[stream] %s returned empty stream after %s message "
"(no text, no tool calls). Treating as a no-op turn. "
"If this repeats, the agent may be stuck — check for "
"ghost empty assistant messages in conversation history.",
self.model,
last_role,
)
for event in tail_events:
@@ -937,26 +958,30 @@ class LiteLLMProvider(LLMProvider):
yield event
return
wait = _compute_retry_delay(attempt)
token_count, token_method = _estimate_tokens(
self.model,
full_messages,
)
dump_path = _dump_failed_request(
model=self.model,
kwargs=kwargs,
error_type="empty_stream",
attempt=attempt,
)
logger.warning(
f"[stream-retry] {self.model} returned empty stream — "
f"~{token_count} tokens ({token_method}). "
f"Request dumped to: {dump_path}. "
f"Retrying in {wait}s "
f"(attempt {attempt + 1}/{RATE_LIMIT_MAX_RETRIES})"
)
await asyncio.sleep(wait)
continue
# Empty stream after a user message — use short fixed
# retries, not the rate-limit backoff. This is likely
# a deterministic conversation-structure issue, so long
# exponential waits don't help.
if attempt < EMPTY_STREAM_MAX_RETRIES:
token_count, token_method = _estimate_tokens(
self.model,
full_messages,
)
dump_path = _dump_failed_request(
model=self.model,
kwargs=kwargs,
error_type="empty_stream",
attempt=attempt,
)
logger.warning(
f"[stream-retry] {self.model} returned empty stream — "
f"~{token_count} tokens ({token_method}). "
f"Request dumped to: {dump_path}. "
f"Retrying in {EMPTY_STREAM_RETRY_DELAY}s "
f"(attempt {attempt + 1}/{EMPTY_STREAM_MAX_RETRIES})"
)
await asyncio.sleep(EMPTY_STREAM_RETRY_DELAY)
continue
# Success (or final attempt) — flush remaining events.
for event in tail_events:
+1 -1
View File
@@ -1946,7 +1946,7 @@ def get_session_status() -> str:
@mcp.tool()
def configure_loop(
max_iterations: Annotated[int, "Maximum loop iterations per node execution (default 50)"] = 50,
max_tool_calls_per_turn: Annotated[int, "Maximum tool calls per LLM turn (default 10)"] = 10,
max_tool_calls_per_turn: Annotated[int, "Maximum tool calls per LLM turn (default 30)"] = 30,
stall_detection_threshold: Annotated[
int, "Consecutive identical responses before stall detection triggers (default 3)"
] = 3,
+9 -1
View File
@@ -435,7 +435,15 @@ class ToolRegistry:
filtered_context = {
k: v for k, v in base_context.items() if k in tool_params
}
merged_inputs = {**filtered_context, **inputs}
# Strip context params from LLM inputs — the framework
# values are authoritative (prevents the LLM from passing
# e.g. data_dir="/data" and overriding the real path).
clean_inputs = {
k: v
for k, v in inputs.items()
if k not in registry_ref.CONTEXT_PARAMS
}
merged_inputs = {**clean_inputs, **filtered_context}
result = client_ref.call_tool(tool_name, merged_inputs)
# MCP tools return content array, extract the result
if isinstance(result, list) and len(result) > 0:
+31 -3
View File
@@ -570,8 +570,7 @@ class ExecutionStream:
if not _is_shared_session:
await self._write_session_state(execution_id, ctx, result=result)
# Emit completion/failure event
# (skip for pauses — executor already emitted execution_paused)
# Emit completion/failure/pause event
if self._scoped_event_bus:
if result.success:
await self._scoped_event_bus.emit_execution_completed(
@@ -580,7 +579,17 @@ class ExecutionStream:
output=result.output,
correlation_id=ctx.correlation_id,
)
elif not result.paused_at:
elif result.paused_at:
# The executor returns paused_at on CancelledError but
# does NOT emit execution_paused itself — we must emit
# it here so the frontend can transition out of "running".
await self._scoped_event_bus.emit_execution_paused(
stream_id=self.stream_id,
node_id=result.paused_at,
reason=result.error or "Execution paused",
execution_id=execution_id,
)
else:
await self._scoped_event_bus.emit_execution_failed(
stream_id=self.stream_id,
execution_id=execution_id,
@@ -629,6 +638,25 @@ class ExecutionStream:
execution_id, ctx, error="Execution cancelled"
)
# Emit SSE event so the frontend knows the execution stopped.
# The executor does NOT emit on CancelledError, so there is no
# risk of double-emitting.
if self._scoped_event_bus:
if has_result and result.paused_at:
await self._scoped_event_bus.emit_execution_paused(
stream_id=self.stream_id,
node_id=result.paused_at,
reason="Execution cancelled",
execution_id=execution_id,
)
else:
await self._scoped_event_bus.emit_execution_failed(
stream_id=self.stream_id,
execution_id=execution_id,
error="Execution cancelled",
correlation_id=ctx.correlation_id,
)
# Don't re-raise - we've handled it and saved state
except Exception as e:
+6 -13
View File
@@ -139,25 +139,18 @@ def create_app(model: str | None = None) -> web.Application:
try:
from framework.credentials.validation import ensure_credential_key_env
# Load ALL credentials: HIVE_CREDENTIAL_KEY, ADEN_API_KEY, and LLM keys
ensure_credential_key_env()
# Ensure HIVE_CREDENTIAL_KEY exists and is persisted (same as TUI setup flow).
# ensure_credential_key_env() loads from shell config but won't generate a new
# key. Web-only users who never ran the TUI need one auto-generated + persisted
# so credentials survive server restarts.
# Auto-generate credential key for web-only users who never ran the TUI
if not os.environ.get("HIVE_CREDENTIAL_KEY"):
try:
from aden_tools.credentials.shell_config import add_env_var_to_shell_config
from cryptography.fernet import Fernet
from framework.credentials.key_storage import generate_and_save_credential_key
generated_key = Fernet.generate_key().decode()
os.environ["HIVE_CREDENTIAL_KEY"] = generated_key
add_env_var_to_shell_config(
"HIVE_CREDENTIAL_KEY",
generated_key,
comment="Encryption key for Hive credential store",
generate_and_save_credential_key()
logger.info(
"Generated and persisted HIVE_CREDENTIAL_KEY to ~/.hive/secrets/credential_key"
)
logger.info("Generated and persisted HIVE_CREDENTIAL_KEY to shell config")
except Exception as exc:
logger.warning("Could not auto-persist HIVE_CREDENTIAL_KEY: %s", exc)
+9 -21
View File
@@ -2,7 +2,6 @@
import asyncio
import logging
import os
from aiohttp import web
from pydantic import SecretStr
@@ -63,25 +62,15 @@ async def handle_save_credential(request: web.Request) -> web.Response:
if not credential_id or not keys or not isinstance(keys, dict):
return web.json_response({"error": "credential_id and keys are required"}, status=400)
# ADEN_API_KEY lives in env + shell config, not the encrypted store
# ADEN_API_KEY is stored in the encrypted store via key_storage module
if credential_id == "aden_api_key":
key = keys.get("api_key", "").strip()
if not key:
return web.json_response({"error": "api_key is required"}, status=400)
os.environ["ADEN_API_KEY"] = key
from framework.credentials.key_storage import save_aden_api_key
# Persist to shell config (best-effort, same pattern as TUI setup)
try:
from aden_tools.credentials.shell_config import add_env_var_to_shell_config
add_env_var_to_shell_config(
"ADEN_API_KEY",
key,
comment="Aden Platform API key",
)
except Exception as exc:
logger.warning("Could not persist ADEN_API_KEY to shell config: %s", exc)
save_aden_api_key(key)
# Immediately sync OAuth tokens from Aden (runs in executor because
# _presync_aden_tokens makes blocking HTTP calls to the Aden server).
@@ -111,13 +100,9 @@ async def handle_delete_credential(request: web.Request) -> web.Response:
credential_id = request.match_info["credential_id"]
if credential_id == "aden_api_key":
os.environ.pop("ADEN_API_KEY", None)
try:
from aden_tools.credentials.shell_config import remove_env_var_from_shell_config
from framework.credentials.key_storage import delete_aden_api_key
remove_env_var_from_shell_config("ADEN_API_KEY")
except Exception as exc:
logger.warning("Could not remove ADEN_API_KEY from shell config: %s", exc)
delete_aden_api_key()
return web.json_response({"deleted": True})
store = _get_store(request)
@@ -154,7 +139,9 @@ async def handle_check_agent(request: web.Request) -> web.Response:
ensure_credential_key_env()
nodes = load_agent_nodes(agent_path)
result = validate_agent_credentials(nodes, verify=verify, raise_on_error=False)
result = validate_agent_credentials(
nodes, verify=verify, raise_on_error=False, force_refresh=True
)
# If any credential needs Aden, include ADEN_API_KEY as a first-class row
if any(c.aden_supported for c in result.credentials):
@@ -204,6 +191,7 @@ def _status_to_dict(c) -> dict:
"credential_key": c.credential_key,
"valid": c.valid,
"validation_message": c.validation_message,
"alternative_group": c.alternative_group,
}
+19 -3
View File
@@ -113,27 +113,43 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
sse = SSEResponse()
await sse.prepare(request)
logger.info(
"SSE connected: session='%s', sub_id='%s', types=%d", session.id, sub_id, len(event_types)
)
event_count = 0
close_reason = "unknown"
try:
while True:
try:
data = await asyncio.wait_for(queue.get(), timeout=KEEPALIVE_INTERVAL)
await sse.send_event(data)
event_count += 1
if event_count == 1:
logger.info(
"SSE first event: session='%s', type='%s'", session.id, data.get("type")
)
except TimeoutError:
await sse.send_keepalive()
except (ConnectionResetError, ConnectionError):
close_reason = "client_disconnected"
break
except Exception as exc:
logger.debug("SSE stream closed: %s", exc)
close_reason = f"error: {exc}"
break
except asyncio.CancelledError:
pass
close_reason = "cancelled"
finally:
try:
event_bus.unsubscribe(sub_id)
except Exception:
pass
logger.debug("SSE client disconnected from session '%s'", session.id)
logger.info(
"SSE disconnected: session='%s', events_sent=%d, reason='%s'",
session.id,
event_count,
close_reason,
)
return sse.response
+2 -13
View File
@@ -160,20 +160,9 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
aden_input = self.query_one("#key-aden", Input)
aden_key = aden_input.value.strip()
if aden_key:
os.environ["ADEN_API_KEY"] = aden_key
# Persist to shell config
try:
from aden_tools.credentials.shell_config import (
add_env_var_to_shell_config,
)
from framework.credentials.key_storage import save_aden_api_key
add_env_var_to_shell_config(
"ADEN_API_KEY",
aden_key,
comment="Aden Platform API key",
)
except Exception:
pass
save_aden_api_key(aden_key)
configured += 1 # ADEN_API_KEY itself counts as configured
# Run Aden sync for all Aden-backed creds (best-effort)
+1
View File
@@ -22,6 +22,7 @@ export interface AgentCredentialRequirement {
direct_api_key_supported: boolean;
aden_supported: boolean;
credential_key: string;
alternative_group: string | null;
}
export const credentialsApi = {
+20 -20
View File
@@ -136,11 +136,12 @@ const triggerIcons: Record<string, string> = {
event: "\u223F", // sine wave
};
function formatLabel(id: string): string {
return id
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
/** Truncate label to fit within `availablePx` at the given fontSize. */
function truncateLabel(label: string, availablePx: number, fontSize: number): string {
const avgCharW = fontSize * 0.58;
const maxChars = Math.floor(availablePx / avgCharW);
if (label.length <= maxChars) return label;
return label.slice(0, Math.max(maxChars - 1, 1)) + "\u2026";
}
export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, onPause, version, runState: externalRunState }: AgentGraphProps) {
@@ -403,10 +404,13 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
const renderTriggerNode = (node: GraphNode, i: number) => {
const pos = nodePos(i);
const icon = triggerIcons[node.triggerType || ""] || "\u26A1";
const clipId = `clip-trigger-${node.id}`;
const triggerFontSize = nodeW < 140 ? 10.5 : 11.5;
const triggerAvailW = nodeW - 38;
const triggerDisplayLabel = truncateLabel(node.label, triggerAvailW, triggerFontSize);
return (
<g key={node.id} onClick={() => onNodeClick?.(node)} style={{ cursor: onNodeClick ? "pointer" : "default" }}>
<title>{node.label}</title>
{/* Pill-shaped background with dashed border */}
<rect
x={pos.x} y={pos.y}
@@ -428,19 +432,15 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
</text>
{/* Label */}
<clipPath id={clipId}>
<rect x={pos.x + 30} y={pos.y} width={nodeW - 38} height={NODE_H} />
</clipPath>
<text
x={pos.x + 32} y={pos.y + NODE_H / 2}
fill={triggerColors.text}
fontSize={nodeW < 140 ? 10.5 : 11.5}
fontSize={triggerFontSize}
fontWeight={500}
dominantBaseline="middle"
letterSpacing="0.01em"
clipPath={`url(#${clipId})`}
>
{node.label}
{triggerDisplayLabel}
</text>
</g>
);
@@ -453,10 +453,14 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
const isActive = node.status === "running" || node.status === "looping";
const isDone = node.status === "complete";
const colors = statusColors[node.status];
const clipId = `clip-label-${node.id}`;
const fontSize = nodeW < 140 ? 10.5 : 12.5;
const labelAvailW = nodeW - 38;
const displayLabel = truncateLabel(node.label, labelAvailW, fontSize);
return (
<g key={node.id} onClick={() => onNodeClick?.(node)} style={{ cursor: onNodeClick ? "pointer" : "default" }}>
<title>{node.label}</title>
{/* Ambient glow for active nodes */}
{isActive && (
<>
@@ -504,20 +508,16 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
</text>
)}
{/* Label -- properly capitalized, clipped for narrow nodes */}
<clipPath id={clipId}>
<rect x={pos.x + 30} y={pos.y} width={nodeW - 38} height={NODE_H} />
</clipPath>
{/* Label -- truncated with ellipsis for narrow nodes */}
<text
x={pos.x + 32} y={pos.y + NODE_H / 2}
fill={isActive ? "hsl(45,90%,85%)" : isDone ? "hsl(40,20%,75%)" : "hsl(35,10%,45%)"}
fontSize={nodeW < 140 ? 10.5 : 12.5}
fontSize={fontSize}
fontWeight={isActive ? 600 : isDone ? 500 : 400}
dominantBaseline="middle"
letterSpacing="0.01em"
clipPath={`url(#${clipId})`}
>
{formatLabel(node.id)}
{displayLabel}
</text>
{/* Status label for active nodes */}
+89 -12
View File
@@ -1,5 +1,5 @@
import { memo, useState, useRef, useEffect } from "react";
import { Send, Square, Crown, Cpu } from "lucide-react";
import { Send, Square, Crown, Cpu, Check, ChevronRight, Loader2 } from "lucide-react";
import { formatAgentDisplayName } from "@/lib/chat-helpers";
import MarkdownContent from "@/components/MarkdownContent";
@@ -35,6 +35,75 @@ function getColor(_agent: string, role?: "queen" | "worker"): string {
return "hsl(220,60%,55%)";
}
function ToolActivityRow({ content }: { content: string }) {
const [expanded, setExpanded] = useState(false);
let tools: { name: string; done: boolean }[] = [];
let allDone = false;
try {
const parsed = JSON.parse(content);
tools = parsed.tools || [];
allDone = parsed.allDone ?? false;
} catch {
// Legacy plain-text fallback
return (
<div className="flex gap-3 pl-10">
<span className="text-[11px] text-muted-foreground bg-muted/40 px-3 py-1 rounded-full border border-border/40">
{content}
</span>
</div>
);
}
if (tools.length === 0) return null;
const total = tools.length;
if (allDone && !expanded) {
return (
<div className="flex gap-3 pl-10">
<button
onClick={() => setExpanded(true)}
className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className="w-3 h-3" />
<Check className="w-3 h-3 text-emerald-500" />
<span>{total} tool{total === 1 ? "" : "s"} used</span>
</button>
</div>
);
}
return (
<div className="flex gap-3 pl-10">
<div className="flex flex-wrap items-center gap-1.5">
{allDone && (
<button onClick={() => setExpanded(false)} className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronRight className="w-3 h-3 rotate-90" />
</button>
)}
{tools.map((t, i) => (
<span
key={i}
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border ${
t.done
? "text-emerald-600 bg-emerald-500/10 border-emerald-500/20"
: "text-muted-foreground bg-muted/40 border-border/40"
}`}
>
{t.done ? (
<Check className="w-2.5 h-2.5" />
) : (
<Loader2 className="w-2.5 h-2.5 animate-spin" />
)}
{t.name}
</span>
))}
</div>
</div>
);
}
const MessageBubble = memo(function MessageBubble({ msg }: { msg: ChatMessage }) {
const isUser = msg.type === "user";
const isQueen = msg.role === "queen";
@@ -51,13 +120,7 @@ const MessageBubble = memo(function MessageBubble({ msg }: { msg: ChatMessage })
}
if (msg.type === "tool_status") {
return (
<div className="flex gap-3 pl-10">
<span className="text-[11px] text-muted-foreground bg-muted/40 px-3 py-1 rounded-full border border-border/40">
{msg.content}
</span>
</div>
);
return <ToolActivityRow content={msg.content} />;
}
if (isUser) {
@@ -115,12 +178,12 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
const [input, setInput] = useState("");
const [readMap, setReadMap] = useState<Record<string, number>>({});
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const threadMessages = messages.filter((m) => {
if (m.type === "system" && !m.thread) return false;
return m.thread === activeThread;
});
console.log('[ChatPanel] render: messages:', messages.length, 'threadMessages:', threadMessages.length, 'activeThread:', activeThread, 'threads:', [...new Set(messages.map(m => m.thread))]);
// Mark current thread as read
useEffect(() => {
@@ -141,6 +204,7 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
if (!input.trim()) return;
onSend(input.trim(), activeThread);
setInput("");
if (textareaRef.current) textareaRef.current.style.height = "auto";
};
const activeWorkerLabel = formatAgentDisplayName(activeThread);
@@ -178,9 +242,22 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t border-border">
<div className="flex items-center gap-3 bg-muted/40 rounded-xl px-4 py-2.5 border border-border focus-within:border-primary/40 transition-colors">
<input
<textarea
ref={textareaRef}
rows={1}
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={(e) => {
setInput(e.target.value);
const ta = e.target;
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={
disabled
? "Connecting to agent..."
@@ -189,7 +266,7 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
: `Message ${activeWorkerLabel}...`
}
disabled={disabled}
className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50 disabled:cursor-not-allowed resize-none overflow-y-auto"
/>
{isWaiting && onCancel ? (
<button
@@ -40,6 +40,7 @@ interface CredentialRow {
adenSupported: boolean; // whether this credential uses OAuth via Aden
valid: boolean | null; // true = health check passed, false = failed, null = not checked
validationMessage: string | null;
alternativeGroup: string | null; // non-null when multiple providers can satisfy a tool
}
function requirementToRow(r: AgentCredentialRequirement): CredentialRow {
@@ -54,6 +55,7 @@ function requirementToRow(r: AgentCredentialRequirement): CredentialRow {
adenSupported: r.aden_supported,
valid: r.valid,
validationMessage: r.validation_message,
alternativeGroup: r.alternative_group ?? null,
};
}
@@ -61,6 +63,16 @@ function requirementToRow(r: AgentCredentialRequirement): CredentialRow {
// Cleared on save/delete so the next fetch picks up updated availability.
const credentialCache = new Map<string, AgentCredentialRequirement[]>();
/** Clear cached credential requirements so the next modal open fetches fresh data.
* Call with a specific path to clear one entry, or no args to clear all. */
export function clearCredentialCache(agentPath?: string) {
if (agentPath) {
credentialCache.delete(agentPath);
} else {
credentialCache.clear();
}
}
interface CredentialsModalProps {
agentType: string;
agentLabel: string;
@@ -123,6 +135,7 @@ export default function CredentialsModal({
adenSupported: false,
valid: null,
validationMessage: null,
alternativeGroup: null,
})));
} else {
setRows([]);
@@ -225,11 +238,28 @@ export default function CredentialsModal({
if (!open) return null;
const connectedCount = rows.filter(c => c.connected).length;
const requiredCount = rows.filter(c => c.required).length;
const requiredConnected = rows.filter(c => c.required && c.connected).length;
const invalidCount = rows.filter(c => c.valid === false).length;
const missingCount = requiredCount - requiredConnected;
const allRequiredMet = requiredConnected === requiredCount && invalidCount === 0;
// Alternative groups (e.g. send_email → resend OR google): satisfied if ANY is connected & valid
const altGroups = new Map<string, boolean>();
for (const c of rows) {
if (!c.alternativeGroup) continue;
if (!altGroups.has(c.alternativeGroup)) altGroups.set(c.alternativeGroup, false);
if (c.connected && c.valid !== false) altGroups.set(c.alternativeGroup, true);
}
const altGroupsSatisfied = altGroups.size === 0 || [...altGroups.values()].every(Boolean);
// Non-alternative required credentials
const nonAltRequired = rows.filter(c => c.required && !c.alternativeGroup);
const nonAltMet = nonAltRequired.every(c => c.connected && c.valid !== false);
const allRequiredMet = nonAltMet && altGroupsSatisfied;
// For status banner counts
const nonAltMissing = nonAltRequired.filter(c => !c.connected).length;
const altGroupsMissing = [...altGroups.values()].filter(v => !v).length;
const missingCount = nonAltMissing + altGroupsMissing;
const adenPlatformConnected = rows.find(r => r.id === "aden_api_key")?.connected ?? false;
return (
@@ -314,13 +344,23 @@ export default function CredentialsModal({
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{row.name}</span>
{row.required && (
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
row.connected
? "text-emerald-600/70 bg-emerald-500/10"
: "text-destructive/70 bg-destructive/10"
}`}>
Required
</span>
row.alternativeGroup ? (
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
row.connected
? "text-emerald-600/70 bg-emerald-500/10"
: "text-amber-600/70 bg-amber-500/10"
}`}>
Either
</span>
) : (
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
row.connected
? "text-emerald-600/70 bg-emerald-500/10"
: "text-destructive/70 bg-destructive/10"
}`}>
Required
</span>
)
)}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">{row.description}</p>
+1 -1
View File
@@ -91,7 +91,7 @@ export default function TopBar({ tabs: tabsProp, onTabClick, onCloseTab, canClos
<div className="flex items-center gap-3 min-w-0">
<button onClick={() => navigate("/")} className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-shrink-0">
<Crown className="w-4 h-4 text-primary" />
<span className="text-sm font-semibold text-primary">Hive</span>
<span className="text-sm font-semibold text-primary">Open Hive</span>
</button>
{tabs.length > 0 && (
+19 -8
View File
@@ -80,28 +80,39 @@ export function useMultiSSE({ sessions, onEvent }: UseMultiSSEOptions) {
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const sourcesRef = useRef(new Map<string, EventSource>());
// Track both the EventSource and its session ID so we can detect session changes
const sourcesRef = useRef(new Map<string, { es: EventSource; sessionId: string }>());
// Diff-based open/close — runs on every `sessions` change
useEffect(() => {
const current = sourcesRef.current;
const desired = new Set(Object.keys(sessions));
// Close connections for sessions no longer in the map
for (const [agentType, es] of current) {
if (!desired.has(agentType)) {
es.close();
// Close connections for removed agents OR changed session IDs
for (const [agentType, entry] of current) {
if (!desired.has(agentType) || sessions[agentType] !== entry.sessionId) {
console.log('[SSE] closing:', agentType, entry.sessionId, desired.has(agentType) ? '(session changed)' : '(removed)');
entry.es.close();
current.delete(agentType);
}
}
// Open connections for newly added sessions
// Open connections for new/changed sessions
for (const [agentType, sessionId] of Object.entries(sessions)) {
if (!sessionId || current.has(agentType)) continue;
const url = `/api/sessions/${sessionId}/events`;
console.log('[SSE] opening:', agentType, sessionId);
const es = new EventSource(url);
es.onopen = () => {
console.log('[SSE] connected:', agentType, sessionId);
};
es.onerror = () => {
console.error('[SSE] error:', agentType, sessionId, 'readyState:', es.readyState);
};
es.onmessage = (e: MessageEvent) => {
try {
const event: AgentEvent = JSON.parse(e.data);
@@ -112,14 +123,14 @@ export function useMultiSSE({ sessions, onEvent }: UseMultiSSEOptions) {
}
};
current.set(agentType, es);
current.set(agentType, { es, sessionId });
}
}, [sessions]);
// Close all on unmount only
useEffect(() => {
return () => {
for (const es of sourcesRef.current.values()) es.close();
for (const entry of sourcesRef.current.values()) entry.es.close();
sourcesRef.current.clear();
};
}, []);
+5
View File
@@ -87,6 +87,11 @@
button {
cursor: pointer;
}
textarea {
padding: 0;
margin: 0;
}
}
* {
+6 -3
View File
@@ -45,15 +45,18 @@ export function topologyToGraphNodes(topology: GraphTopology): GraphNode[] {
adj.set(triggerId, triggerNode.next!);
}
// BFS — start from trigger nodes if any, else entry_node
// BFS — start from trigger nodes (if any), then entry_node.
// Always include entry_node so the DAG ordering stays correct
// even when triggers target a node other than entry.
const order: string[] = [];
const position = new Map<string, number>();
const visited = new Set<string>();
const entryStart = entry_node || nodes[0].id;
const starts =
triggerMap.size > 0
? [...triggerMap.keys()]
: [entry_node || nodes[0].id];
? [...triggerMap.keys(), entryStart]
: [entryStart];
const queue = [...starts];
for (const s of starts) visited.add(s);
+6 -9
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Crown, Mail, Briefcase, Shield, Search, Newspaper, ArrowRight, Hexagon, Send, Bot } from "lucide-react";
import TopBar from "@/components/TopBar";
@@ -40,6 +40,7 @@ const promptHints = [
export default function Home() {
const navigate = useNavigate();
const [inputValue, setInputValue] = useState("");
const textareaRef = useRef<HTMLInputElement>(null);
const [showAgents, setShowAgents] = useState(false);
const [agents, setAgents] = useState<DiscoverEntry[]>([]);
const [loading, setLoading] = useState(false);
@@ -106,18 +107,14 @@ export default function Home() {
<form onSubmit={handleSubmit} className="mb-6">
<div className="relative border border-border/60 rounded-xl bg-card/50 hover:border-primary/30 focus-within:border-primary/40 transition-colors shadow-sm">
<input
ref={textareaRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder="Describe a task for the hive..."
className="w-full bg-transparent px-5 py-3 pr-12 text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none rounded-xl"
className="w-full bg-transparent px-5 py-4 pr-12 text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none rounded-xl"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="absolute right-3 bottom-2.5">
<button
type="submit"
disabled={!inputValue.trim()}
+98 -185
View File
@@ -7,7 +7,7 @@ import ChatPanel, { type ChatMessage } from "@/components/ChatPanel";
import TopBar from "@/components/TopBar";
import { TAB_STORAGE_KEY, loadPersistedTabs, savePersistedTabs, type PersistedTabState } from "@/lib/tab-persistence";
import NodeDetailPanel from "@/components/NodeDetailPanel";
import CredentialsModal, { type Credential, createFreshCredentials, cloneCredentials, allRequiredCredentialsMet } from "@/components/CredentialsModal";
import CredentialsModal, { type Credential, createFreshCredentials, cloneCredentials, allRequiredCredentialsMet, clearCredentialCache } from "@/components/CredentialsModal";
import { agentsApi } from "@/api/agents";
import { executionApi } from "@/api/execution";
import { graphsApi } from "@/api/graphs";
@@ -16,6 +16,7 @@ import { useMultiSSE } from "@/hooks/use-sse";
import type { LiveSession, AgentEvent, DiscoverEntry, Message, NodeSpec } from "@/api/types";
import { backendMessageToChatMessage, sseEventToChatMessage, formatAgentDisplayName } from "@/lib/chat-helpers";
import { topologyToGraphNodes } from "@/lib/graph-converter";
import { ApiError } from "@/api/client";
const makeId = () => Math.random().toString(36).slice(2, 9);
@@ -181,80 +182,6 @@ function NewTabPopover({ open, onClose, anchorRef, discoverAgents, onFromScratch
);
}
// --- LoadAgentPopover ---
interface LoadAgentPopoverProps {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement | null>;
discoverAgents: DiscoverEntry[];
onSelect: (agentPath: string) => void;
}
function LoadAgentPopover({ open, onClose, anchorRef, discoverAgents, onSelect }: LoadAgentPopoverProps) {
const [pos, setPos] = useState<{ top: number; right: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open && anchorRef.current) {
const rect = anchorRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}
}, [open, anchorRef]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (
ref.current && !ref.current.contains(e.target as Node) &&
anchorRef.current && !anchorRef.current.contains(e.target as Node)
) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open, onClose, anchorRef]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open || !pos) return null;
return ReactDOM.createPortal(
<div
ref={ref}
style={{ position: "fixed", top: pos.top, right: pos.right, zIndex: 9999 }}
className="w-60 rounded-xl border border-border/60 bg-card shadow-xl shadow-black/30 overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/40">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Load Agent
</span>
</div>
<div className="p-1.5 flex flex-col max-h-64 overflow-y-auto">
{discoverAgents.map(agent => (
<button
key={agent.path}
onClick={() => { onSelect(agent.path); onClose(); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-left transition-colors hover:bg-muted/60 text-foreground"
>
<div className="w-6 h-6 rounded-md bg-muted/80 flex items-center justify-center flex-shrink-0">
<Bot className="w-3.5 h-3.5 text-muted-foreground" />
</div>
<span className="text-sm font-medium">{agent.name}</span>
</button>
))}
{discoverAgents.length === 0 && (
<p className="text-xs text-muted-foreground px-3 py-2">No agents found</p>
)}
</div>
</div>,
document.body
);
}
function fmtLogTs(ts: string): string {
try {
const d = new Date(ts);
@@ -286,7 +213,7 @@ interface AgentBackendState {
isTyping: boolean;
isStreaming: boolean;
llmSnapshots: Record<string, string>;
toolCallCounts: Record<string, number>;
activeToolCalls: Record<string, { name: string; done: boolean; streamId: string }>;
}
function defaultAgentState(): AgentBackendState {
@@ -307,7 +234,7 @@ function defaultAgentState(): AgentBackendState {
isTyping: false,
isStreaming: false,
llmSnapshots: {},
toolCallCounts: {},
activeToolCalls: {},
};
}
@@ -398,9 +325,6 @@ export default function Workspace() {
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [newTabOpen, setNewTabOpen] = useState(false);
const newTabBtnRef = useRef<HTMLButtonElement>(null);
const [loadAgentOpen, setLoadAgentOpen] = useState(false);
const [loadingWorker, setLoadingWorker] = useState(false);
const loadAgentBtnRef = useRef<HTMLButtonElement>(null);
// Ref mirror of sessionsByAgent so SSE callback can read current graph
// state without adding sessionsByAgent to its dependency array.
@@ -460,14 +384,18 @@ export default function Workspace() {
const handleRun = useCallback(async () => {
const state = agentStates[activeWorker];
if (!state?.sessionId || !state?.ready) return;
// Reset dismissed banner so a repeated 424 re-shows it
setDismissedBanner(null);
try {
updateAgentState(activeWorker, { workerRunState: "deploying" });
const result = await executionApi.trigger(state.sessionId, "default", {});
updateAgentState(activeWorker, { currentExecutionId: result.execution_id });
} catch (err) {
// 424 = credentials required — open the credentials modal
const { ApiError } = await import("@/api/client");
if (err instanceof ApiError && err.status === 424) {
const errBody = (err as ApiError).body as Record<string, unknown>;
const credPath = (errBody?.agent_path as string) || null;
if (credPath) setCredentialAgentPath(credPath);
updateAgentState(activeWorker, { workerRunState: "idle", error: "credentials_required" });
setCredentialsOpen(true);
return;
@@ -610,10 +538,11 @@ export default function Workspace() {
try {
liveSession = await sessionsApi.create(agentType);
} catch (loadErr: unknown) {
const { ApiError } = await import("@/api/client");
// 424 = credentials required — open the credentials modal
if (loadErr instanceof ApiError && loadErr.status === 424) {
const errBody = loadErr.body as Record<string, unknown>;
const credPath = (errBody.agent_path as string) || null;
if (credPath) setCredentialAgentPath(credPath);
updateAgentState(agentType, { loading: false, error: "credentials_required" });
setCredentialsOpen(true);
return;
@@ -771,9 +700,16 @@ export default function Workspace() {
}
}, [updateAgentState]);
// Track which sessions already have an in-flight or completed graph fetch
// to prevent the flood of duplicate API calls. agentStates changes on every
// SSE event (text delta, tool_call, etc.) which re-triggers this effect
// before the first response has returned.
const fetchedGraphSessionsRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const [agentType, state] of Object.entries(agentStates)) {
if (!state.sessionId || !state.ready || state.nodeSpecs.length > 0 || state.graphId) continue;
if (fetchedGraphSessionsRef.current.has(state.sessionId)) continue;
fetchedGraphSessionsRef.current.add(state.sessionId);
fetchGraphForAgent(agentType, state.sessionId);
}
}, [agentStates, fetchGraphForAgent]);
@@ -964,7 +900,7 @@ export default function Workspace() {
currentExecutionId: event.execution_id || agentStates[agentType]?.currentExecutionId || null,
nodeLogs: {},
llmSnapshots: {},
toolCallCounts: {},
activeToolCalls: {},
});
markAllNodesAs(agentType, ["running", "looping", "complete", "error"], "pending");
}
@@ -1054,7 +990,7 @@ export default function Workspace() {
case "node_loop_started":
turnCounterRef.current[agentType] = currentTurn + 1;
updateAgentState(agentType, { isTyping: true });
updateAgentState(agentType, { isTyping: true, activeToolCalls: {} });
if (!isQueen && event.node_id) {
const sessions = sessionsRef.current[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
@@ -1070,7 +1006,7 @@ export default function Workspace() {
case "node_loop_iteration":
turnCounterRef.current[agentType] = currentTurn + 1;
updateAgentState(agentType, { isStreaming: false });
updateAgentState(agentType, { isStreaming: false, activeToolCalls: {} });
if (!isQueen && event.node_id) {
const pendingText = agentStates[agentType]?.llmSnapshots[event.node_id];
if (pendingText?.trim()) {
@@ -1117,58 +1053,58 @@ export default function Workspace() {
case "tool_call_started": {
console.log('[TOOL_PILL] tool_call_started received:', { isQueen, nodeId: event.node_id, streamId: event.stream_id, agentType, executionId: event.execution_id, toolName: event.data?.tool_name });
if (!isQueen && event.node_id) {
const pendingText = agentStates[agentType]?.llmSnapshots[event.node_id];
if (pendingText?.trim()) {
appendNodeLog(agentType, event.node_id, `${ts} INFO LLM: ${truncate(pendingText.trim(), 300)}`);
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const { [event.node_id!]: _, ...rest } = state.llmSnapshots;
return { ...prev, [agentType]: { ...state, llmSnapshots: rest } };
});
if (event.node_id) {
if (!isQueen) {
const pendingText = agentStates[agentType]?.llmSnapshots[event.node_id];
if (pendingText?.trim()) {
appendNodeLog(agentType, event.node_id, `${ts} INFO LLM: ${truncate(pendingText.trim(), 300)}`);
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const { [event.node_id!]: _, ...rest } = state.llmSnapshots;
return { ...prev, [agentType]: { ...state, llmSnapshots: rest } };
});
}
appendNodeLog(agentType, event.node_id, `${ts} INFO Calling ${(event.data?.tool_name as string) || "unknown"}(${event.data?.tool_input ? truncate(JSON.stringify(event.data.tool_input), 200) : ""})`);
}
const toolName = (event.data?.tool_name as string) || "unknown";
const toolInput = event.data?.tool_input;
const argsStr = toolInput ? truncate(JSON.stringify(toolInput), 200) : "";
appendNodeLog(agentType, event.node_id, `${ts} INFO Calling ${toolName}(${argsStr})`);
// Update tool call counts and upsert compact pill into chat
let pillContent = "";
const toolName = (event.data?.tool_name as string) || "unknown";
const toolUseId = (event.data?.tool_use_id as string) || "";
// Track active (in-flight) tools and upsert activity row into chat
const sid = event.stream_id;
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const newCounts = { ...state.toolCallCounts, [toolName]: (state.toolCallCounts[toolName] || 0) + 1 };
pillContent = Object.entries(newCounts).map(([n, c]) => `${c} ${n}`).join(", ");
return {
...prev,
[agentType]: { ...state, isStreaming: false, toolCallCounts: newCounts },
};
});
console.log('[TOOL_PILL] pillContent:', pillContent, 'agentType:', agentType);
if (pillContent) {
const pillMsg: ChatMessage = {
id: `tool-pill-${event.execution_id || "exec"}`,
const newActive = { ...state.activeToolCalls, [toolUseId]: { name: toolName, done: false, streamId: sid } };
// Only include tools from this stream in the pill
const tools = Object.values(newActive).filter(t => t.streamId === sid).map(t => ({ name: t.name, done: t.done }));
const allDone = tools.length > 0 && tools.every(t => t.done);
upsertChatMessage(agentType, {
id: `tool-pill-${sid}-${event.execution_id || "exec"}-${currentTurn}`,
agent: agentDisplayName || event.node_id || "Agent",
agentColor: "",
content: pillContent,
content: JSON.stringify({ tools, allDone }),
timestamp: "",
type: "tool_status",
role: "worker",
role,
thread: agentType,
});
return {
...prev,
[agentType]: { ...state, isStreaming: false, activeToolCalls: newActive },
};
console.log('[TOOL_PILL] upserting:', pillMsg);
upsertChatMessage(agentType, pillMsg);
}
});
} else {
console.log('[TOOL_PILL] SKIPPED: isQueen=', isQueen, 'node_id=', event.node_id);
console.log('[TOOL_PILL] SKIPPED: no node_id', event.node_id);
}
break;
}
case "tool_call_completed":
if (!isQueen && event.node_id) {
case "tool_call_completed": {
if (event.node_id) {
const toolName = (event.data?.tool_name as string) || "unknown";
const toolUseId = (event.data?.tool_use_id as string) || "";
const isError = event.data?.is_error as boolean | undefined;
const result = event.data?.result as string | undefined;
if (isError) {
@@ -1177,8 +1113,36 @@ export default function Workspace() {
const resultStr = result ? ` (${truncate(result, 200)})` : "";
appendNodeLog(agentType, event.node_id, `${ts} INFO ${toolName} done${resultStr}`);
}
// Mark tool as done and update activity row
const sid = event.stream_id;
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const updated = { ...state.activeToolCalls };
if (updated[toolUseId]) {
updated[toolUseId] = { ...updated[toolUseId], done: true };
}
const tools = Object.values(updated).filter(t => t.streamId === sid).map(t => ({ name: t.name, done: t.done }));
const allDone = tools.length > 0 && tools.every(t => t.done);
upsertChatMessage(agentType, {
id: `tool-pill-${sid}-${event.execution_id || "exec"}-${currentTurn}`,
agent: agentDisplayName || event.node_id || "Agent",
agentColor: "",
content: JSON.stringify({ tools, allDone }),
timestamp: "",
type: "tool_status",
role,
thread: agentType,
});
return {
...prev,
[agentType]: { ...state, activeToolCalls: updated },
};
});
}
break;
}
case "node_internal_output":
if (!isQueen && event.node_id) {
@@ -1239,15 +1203,25 @@ export default function Workspace() {
}
break;
case "credentials_required":
case "credentials_required": {
updateAgentState(agentType, { workerRunState: "idle", error: "credentials_required" });
const credAgentPath = event.data?.agent_path as string | undefined;
if (credAgentPath) setCredentialAgentPath(credAgentPath);
setCredentialsOpen(true);
break;
}
case "worker_loaded": {
const workerName = event.data?.worker_name as string | undefined;
const agentPathFromEvent = event.data?.agent_path as string | undefined;
const displayName = formatAgentDisplayName(workerName || agentType);
// Invalidate cached credential requirements so the modal fetches
// fresh data the next time it opens (the new agent may have
// different credential needs than the previous one).
clearCredentialCache(agentPathFromEvent);
clearCredentialCache(agentType);
// Update agent state: new display name, reset graph so topology refetch triggers
updateAgentState(agentType, {
displayName,
@@ -1407,43 +1381,6 @@ export default function Workspace() {
}
}, [activeWorker, activeSession, agentStates, updateAgentState]);
const handleLoadAgent = useCallback(async (agentPath: string) => {
const state = agentStates[activeWorker];
if (!state?.sessionId) return;
setLoadingWorker(true);
try {
await sessionsApi.loadWorker(state.sessionId, agentPath);
// Success: worker_loaded SSE event will handle UI updates automatically
} catch (err) {
// 424 = credentials required — open the credentials modal
const { ApiError } = await import("@/api/client");
if (err instanceof ApiError && err.status === 424) {
const body = err.body as Record<string, unknown>;
setCredentialAgentPath((body.agent_path as string) || null);
setCredentialsOpen(true);
setLoadingWorker(false);
return;
}
const errMsg = err instanceof Error ? err.message : String(err);
const activeId = activeSessionRef.current[activeWorker];
const errorMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: `Failed to load agent: ${errMsg}`,
timestamp: "", type: "system", thread: activeWorker,
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: (prev[activeWorker] || []).map(s =>
s.id === activeId ? { ...s, messages: [...s.messages, errorMsg] } : s
),
}));
} finally {
setLoadingWorker(false);
}
}, [activeWorker, agentStates]);
const closeAgentTab = useCallback((agentType: string) => {
setSelectedNode(null);
// Pause worker execution if running (saves checkpoint), then kill the
@@ -1542,30 +1479,6 @@ export default function Workspace() {
</>
}
>
{activeWorker === "new-agent" && activeAgentState?.ready && !activeAgentState?.graphId && (
<>
<button
ref={loadAgentBtnRef}
onClick={() => setLoadAgentOpen(o => !o)}
disabled={loadingWorker}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
>
{loadingWorker ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Bot className="w-3.5 h-3.5" />
)}
Load Agent
</button>
<LoadAgentPopover
open={loadAgentOpen}
onClose={() => setLoadAgentOpen(false)}
anchorRef={loadAgentBtnRef}
discoverAgents={discoverAgents}
onSelect={handleLoadAgent}
/>
</>
)}
<button
onClick={() => setCredentialsOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
@@ -1729,14 +1642,14 @@ export default function Workspace() {
agentLabel={activeWorkerLabel}
agentPath={credentialAgentPath || (activeWorker !== "new-agent" ? activeWorker : undefined)}
open={credentialsOpen}
onClose={() => { setCredentialsOpen(false); setCredentialAgentPath(null); }}
onClose={() => { setCredentialsOpen(false); setCredentialAgentPath(null); setDismissedBanner(null); }}
credentials={activeSession?.credentials || []}
onCredentialChange={() => {
if (!activeSession) return;
// Clear credential error so the auto-load effect retries session creation
if (agentStates[activeWorker]?.error === "credentials_required") {
updateAgentState(activeWorker, { error: null });
}
if (!activeSession) return;
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
File diff suppressed because it is too large Load Diff
@@ -214,7 +214,7 @@ class CompetitiveIntelAgent:
max_tokens=self.config.max_tokens,
loop_config={
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
},
)
@@ -195,7 +195,7 @@ class DeepResearchAgent:
max_tokens=self.config.max_tokens,
loop_config={
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
},
)
@@ -71,6 +71,12 @@ Important:
- Track which URL each finding comes from (you'll need citations later)
- Call set_output for each key in a SEPARATE turn (not in the same turn as other tool calls)
Context management:
- Your tool results are automatically saved to files. After compaction, the file \
references remain in the conversation use load_data() to recover any content you need.
- Use append_data('research_notes.md', ...) to maintain a running log of key findings \
as you go. This survives compaction and helps the report node produce a detailed report.
When done, use set_output (one key at a time, separate turns):
- set_output("findings", "Structured summary: key findings with source URLs for each claim. \
Include themes, contradictions, and confidence levels.")
@@ -246,8 +252,17 @@ report covers. Ask if they have questions.
- Every factual claim MUST cite its source with [n] notation
- Answer the original research questions from the brief
- If an append_data call fails with a truncation error, break it into smaller chunks
- If findings appear incomplete or summarized, call list_data_files() and load_data() \
to access the detailed source material from the research phase. The research node's \
tool results and research_notes.md contain the full data.
""",
tools=["save_data", "append_data", "serve_file_to_user"],
tools=[
"save_data",
"append_data",
"serve_file_to_user",
"load_data",
"list_data_files",
],
)
__all__ = [
+1 -1
View File
@@ -179,7 +179,7 @@ class JobHunterAgent:
max_tokens=self.config.max_tokens,
loop_config={
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
},
conversation_mode="continuous",
@@ -152,7 +152,7 @@ class TechNewsReporterAgent:
max_tokens=self.config.max_tokens,
loop_config={
"max_iterations": 50,
"max_tool_calls_per_turn": 10,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
},
)
@@ -210,7 +210,7 @@ class VulnerabilityResearcherAgent:
max_tokens=self.config.max_tokens,
loop_config={
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
},
conversation_mode="continuous",
+12 -3
View File
@@ -63,9 +63,18 @@ if (Test-Path $configPath) {
}
# Load HIVE_CREDENTIAL_KEY for encrypted credential store
$credKey = [System.Environment]::GetEnvironmentVariable("HIVE_CREDENTIAL_KEY", "User")
if ($credKey -and -not $env:HIVE_CREDENTIAL_KEY) {
$env:HIVE_CREDENTIAL_KEY = $credKey
if (-not $env:HIVE_CREDENTIAL_KEY) {
# 1. Windows User env var (legacy quickstart installs)
$credKey = [System.Environment]::GetEnvironmentVariable("HIVE_CREDENTIAL_KEY", "User")
if ($credKey) {
$env:HIVE_CREDENTIAL_KEY = $credKey
} else {
# 2. File-based storage (new quickstart + matches quickstart.sh)
$credKeyFile = Join-Path $env:USERPROFILE ".hive\secrets\credential_key"
if (Test-Path $credKeyFile) {
$env:HIVE_CREDENTIAL_KEY = (Get-Content $credKeyFile -Raw).Trim()
}
}
}
# ── Run the Hive CLI ────────────────────────────────────────────────
+400 -148
View File
@@ -416,6 +416,27 @@ $uvVersion = & uv --version
Write-Ok "uv detected: $uvVersion"
Write-Host ""
# Check for Node.js (needed for frontend dashboard)
$NodeAvailable = $false
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
if ($nodeCmd) {
$nodeVersion = & node --version 2>$null
if ($nodeVersion -match '^v(\d+)') {
$nodeMajor = [int]$Matches[1]
if ($nodeMajor -ge 20) {
Write-Ok "Node.js $nodeVersion"
$NodeAvailable = $true
} else {
Write-Warn "Node.js $nodeVersion found (20+ required for frontend dashboard)"
Write-Host " Install from https://nodejs.org" -ForegroundColor DarkGray
}
}
} else {
Write-Warn "Node.js not found (optional, needed for web dashboard)"
Write-Host " Install from https://nodejs.org" -ForegroundColor DarkGray
}
Write-Host ""
# ============================================================
# Step 2: Install Python Packages
# ============================================================
@@ -469,6 +490,40 @@ Write-Host ""
Write-Ok "All packages installed"
Write-Host ""
# Build frontend (if Node.js is available)
$FrontendBuilt = $false
if ($NodeAvailable) {
Write-Step -Number "" -Text "Building frontend dashboard..."
Write-Host ""
$frontendDir = Join-Path $ScriptDir "core\frontend"
if (Test-Path (Join-Path $frontendDir "package.json")) {
Write-Host " Installing npm packages... " -NoNewline
Push-Location $frontendDir
try {
$null = & npm install --no-fund --no-audit 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Ok "ok"
Write-Host " Building frontend... " -NoNewline
$null = & npm run build 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Ok "ok"
Write-Ok "Frontend built -> core/frontend/dist/"
$FrontendBuilt = $true
} else {
Write-Warn "build failed"
Write-Host " Run 'cd core\frontend && npm run build' manually to debug." -ForegroundColor DarkGray
}
} else {
Write-Warn "npm install failed"
$NodeAvailable = $false
}
} finally {
Pop-Location
}
}
Write-Host ""
}
# ============================================================
# Step 2.5: Windows Defender Exclusions (Optional Performance Boost)
# ============================================================
@@ -700,8 +755,8 @@ $DefaultModels = @{
# Model choices: array of hashtables per provider
$ModelChoices = @{
anthropic = @(
@{ Id = "claude-opus-4-6"; Label = "Opus 4.6 - Most capable (recommended)"; MaxTokens = 8192 },
@{ Id = "claude-sonnet-4-5-20250929"; Label = "Sonnet 4.5 - Best balance"; MaxTokens = 8192 },
@{ Id = "claude-opus-4-6"; Label = "Opus 4.6 - Most capable (recommended)"; MaxTokens = 32768 },
@{ Id = "claude-sonnet-4-5-20250929"; Label = "Sonnet 4.5 - Best balance"; MaxTokens = 16384 },
@{ Id = "claude-sonnet-4-20250514"; Label = "Sonnet 4 - Fast + capable"; MaxTokens = 8192 },
@{ Id = "claude-haiku-4-5-20251001"; Label = "Haiku 4.5 - Fast + cheap"; MaxTokens = 8192 }
)
@@ -761,10 +816,10 @@ function Get-ModelSelection {
}
# ============================================================
# Step 5 (was 3 in bash): Configure LLM API Key
# Configure LLM API Key
# ============================================================
Write-Step -Number "5" -Text "Step 5: Configuring LLM provider..."
Write-Step -Number "" -Text "Configuring LLM provider..."
# Hive config paths
$HiveConfigDir = Join-Path $env:USERPROFILE ".hive"
@@ -774,109 +829,169 @@ $SelectedProviderId = ""
$SelectedEnvVar = ""
$SelectedModel = ""
$SelectedMaxTokens = 8192
$SubscriptionMode = ""
# Scan for existing API keys in the current environment
$FoundProviders = @()
$FoundEnvVars = @()
# ── Credential detection (silent — just set flags) ───────────
$ClaudeCredDetected = $false
$claudeCredPath = Join-Path $env:USERPROFILE ".claude\.credentials.json"
if (Test-Path $claudeCredPath) { $ClaudeCredDetected = $true }
foreach ($envVar in $ProviderMap.Keys) {
$val = [System.Environment]::GetEnvironmentVariable($envVar, "Process")
if (-not $val) { $val = [System.Environment]::GetEnvironmentVariable($envVar, "User") }
if ($val) {
$FoundProviders += $ProviderMap[$envVar].Name
$FoundEnvVars += $envVar
}
$CodexCredDetected = $false
$codexAuthPath = Join-Path $env:USERPROFILE ".codex\auth.json"
if (Test-Path $codexAuthPath) { $CodexCredDetected = $true }
$ZaiCredDetected = $false
$zaiKey = [System.Environment]::GetEnvironmentVariable("ZAI_API_KEY", "User")
if (-not $zaiKey) { $zaiKey = $env:ZAI_API_KEY }
if ($zaiKey) { $ZaiCredDetected = $true }
# Detect API key providers
$ProviderMenuEnvVars = @("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "CEREBRAS_API_KEY")
$ProviderMenuNames = @("Anthropic (Claude) - Recommended", "OpenAI (GPT)", "Google Gemini - Free tier available", "Groq - Fast, free tier", "Cerebras - Fast, free tier")
$ProviderMenuIds = @("anthropic", "openai", "gemini", "groq", "cerebras")
$ProviderMenuUrls = @(
"https://console.anthropic.com/settings/keys",
"https://platform.openai.com/api-keys",
"https://aistudio.google.com/apikey",
"https://console.groq.com/keys",
"https://cloud.cerebras.ai/"
)
# ── Show unified provider selection menu ─────────────────────
Write-Color -Text "Select your default LLM provider:" -Color White
Write-Host ""
Write-Color -Text " Subscription modes (no API key purchase needed):" -Color Cyan
# 1) Claude Code
Write-Host " " -NoNewline
Write-Color -Text "1" -Color Cyan -NoNewline
Write-Host ") Claude Code Subscription " -NoNewline
Write-Color -Text "(use your Claude Max/Pro plan)" -Color DarkGray -NoNewline
if ($ClaudeCredDetected) { Write-Color -Text " (credential detected)" -Color Green } else { Write-Host "" }
# 2) ZAI Code
Write-Host " " -NoNewline
Write-Color -Text "2" -Color Cyan -NoNewline
Write-Host ") ZAI Code Subscription " -NoNewline
Write-Color -Text "(use your ZAI Code plan)" -Color DarkGray -NoNewline
if ($ZaiCredDetected) { Write-Color -Text " (credential detected)" -Color Green } else { Write-Host "" }
# 3) Codex
Write-Host " " -NoNewline
Write-Color -Text "3" -Color Cyan -NoNewline
Write-Host ") OpenAI Codex Subscription " -NoNewline
Write-Color -Text "(use your Codex/ChatGPT Plus plan)" -Color DarkGray -NoNewline
if ($CodexCredDetected) { Write-Color -Text " (credential detected)" -Color Green } else { Write-Host "" }
Write-Host ""
Write-Color -Text " API key providers:" -Color Cyan
# 4-8) API key providers
for ($idx = 0; $idx -lt $ProviderMenuEnvVars.Count; $idx++) {
$num = $idx + 4
$envVal = [System.Environment]::GetEnvironmentVariable($ProviderMenuEnvVars[$idx], "Process")
if (-not $envVal) { $envVal = [System.Environment]::GetEnvironmentVariable($ProviderMenuEnvVars[$idx], "User") }
Write-Host " " -NoNewline
Write-Color -Text "$num" -Color Cyan -NoNewline
Write-Host ") $($ProviderMenuNames[$idx])" -NoNewline
if ($envVal) { Write-Color -Text " (credential detected)" -Color Green } else { Write-Host "" }
}
if ($FoundProviders.Count -gt 0) {
Write-Host "Found API keys:"
Write-Host ""
foreach ($p in $FoundProviders) {
Write-Ok $p
}
Write-Host ""
Write-Host " " -NoNewline
Write-Color -Text "9" -Color Cyan -NoNewline
Write-Host ") Skip for now"
Write-Host ""
if ($FoundProviders.Count -eq 1) {
if (Prompt-YesNo "Use this key?") {
$SelectedEnvVar = $FoundEnvVars[0]
$SelectedProviderId = $ProviderMap[$SelectedEnvVar].Id
while ($true) {
$raw = Read-Host "Enter choice (1-9)"
if ($raw -match '^\d+$') {
$num = [int]$raw
if ($num -ge 1 -and $num -le 9) { break }
}
Write-Color -Text "Invalid choice. Please enter 1-9" -Color Red
}
switch ($num) {
1 {
# Claude Code Subscription
if (-not $ClaudeCredDetected) {
Write-Host ""
Write-Ok "Using $($FoundProviders[0])"
$modelSel = Get-ModelSelection $SelectedProviderId
$SelectedModel = $modelSel.Model
$SelectedMaxTokens = $modelSel.MaxTokens
Write-Warn "~/.claude/.credentials.json not found."
Write-Host " Run 'claude' first to authenticate with your Claude subscription,"
Write-Host " then run this quickstart again."
Write-Host ""
exit 1
}
} else {
Write-Color -Text "Select your default LLM provider:" -Color White
$SubscriptionMode = "claude_code"
$SelectedProviderId = "anthropic"
$SelectedModel = "claude-opus-4-6"
$SelectedMaxTokens = 32768
Write-Host ""
for ($i = 0; $i -lt $FoundProviders.Count; $i++) {
Write-Color -Text " $($i + 1)" -Color Cyan -NoNewline
Write-Host ") $($FoundProviders[$i])"
}
$otherIdx = $FoundProviders.Count + 1
Write-Color -Text " $otherIdx" -Color Cyan -NoNewline
Write-Host ") Other"
Write-Ok "Using Claude Code subscription"
}
2 {
# ZAI Code Subscription
$SubscriptionMode = "zai_code"
$SelectedProviderId = "openai"
$SelectedEnvVar = "ZAI_API_KEY"
$SelectedModel = "glm-5"
$SelectedMaxTokens = 32768
Write-Host ""
while ($true) {
$raw = Read-Host "Enter choice (1-$otherIdx)"
if ($raw -match '^\d+$') {
$num = [int]$raw
if ($num -ge 1 -and $num -le $otherIdx) {
if ($num -eq $otherIdx) { break } # fall through to manual selection
$idx = $num - 1
$SelectedEnvVar = $FoundEnvVars[$idx]
$SelectedProviderId = $ProviderMap[$SelectedEnvVar].Id
Write-Ok "Using ZAI Code subscription"
Write-Color -Text " Model: glm-5 | API: api.z.ai" -Color DarkGray
}
3 {
# OpenAI Codex Subscription
if (-not $CodexCredDetected) {
Write-Host ""
Write-Warn "Codex credentials not found. Starting OAuth login..."
Write-Host ""
try {
& uv run python (Join-Path $ScriptDir "core\codex_oauth.py") 2>&1
if ($LASTEXITCODE -eq 0) {
$CodexCredDetected = $true
} else {
Write-Host ""
Write-Ok "Selected: $($FoundProviders[$idx])"
$modelSel = Get-ModelSelection $SelectedProviderId
$SelectedModel = $modelSel.Model
$SelectedMaxTokens = $modelSel.MaxTokens
break
Write-Fail "OAuth login failed or was cancelled."
Write-Host ""
Write-Host " Or run 'codex' to authenticate, then run this quickstart again."
Write-Host ""
$SelectedProviderId = ""
}
} catch {
Write-Fail "OAuth login failed: $($_.Exception.Message)"
$SelectedProviderId = ""
}
Write-Color -Text "Invalid choice. Please enter 1-$otherIdx" -Color Red
}
if ($CodexCredDetected) {
$SubscriptionMode = "codex"
$SelectedProviderId = "openai"
$SelectedModel = "gpt-5.3-codex"
$SelectedMaxTokens = 16384
Write-Host ""
Write-Ok "Using OpenAI Codex subscription"
}
}
}
if (-not $SelectedProviderId) {
$providerOptions = @(
"Anthropic (Claude) - Recommended",
"OpenAI (GPT)",
"Google Gemini - Free tier available",
"Groq - Fast, free tier",
"Cerebras - Fast, free tier",
"Skip for now"
)
$choice = Prompt-Choice "Select your LLM provider:" $providerOptions
$providerDetails = @(
@{ EnvVar = "ANTHROPIC_API_KEY"; Id = "anthropic"; Name = "Anthropic"; Url = "https://console.anthropic.com/settings/keys" },
@{ EnvVar = "OPENAI_API_KEY"; Id = "openai"; Name = "OpenAI"; Url = "https://platform.openai.com/api-keys" },
@{ EnvVar = "GEMINI_API_KEY"; Id = "gemini"; Name = "Google Gemini"; Url = "https://aistudio.google.com/apikey" },
@{ EnvVar = "GROQ_API_KEY"; Id = "groq"; Name = "Groq"; Url = "https://console.groq.com/keys" },
@{ EnvVar = "CEREBRAS_API_KEY"; Id = "cerebras"; Name = "Cerebras"; Url = "https://cloud.cerebras.ai/" }
)
if ($choice -lt 5) {
$det = $providerDetails[$choice]
$SelectedEnvVar = $det.EnvVar
$SelectedProviderId = $det.Id
{ $_ -ge 4 -and $_ -le 8 } {
# API key providers
$provIdx = $num - 4
$SelectedEnvVar = $ProviderMenuEnvVars[$provIdx]
$SelectedProviderId = $ProviderMenuIds[$provIdx]
$providerName = $ProviderMenuNames[$provIdx] -replace ' - .*', '' # strip description
$signupUrl = $ProviderMenuUrls[$provIdx]
# Check if key is already set
$existingKey = [System.Environment]::GetEnvironmentVariable($SelectedEnvVar, "User")
if (-not $existingKey) { $existingKey = [System.Environment]::GetEnvironmentVariable($SelectedEnvVar, "Process") }
if (-not $existingKey) {
Write-Host ""
Write-Host "Get your API key from: " -NoNewline
Write-Color -Text $det.Url -Color Cyan
Write-Color -Text $signupUrl -Color Cyan
Write-Host ""
$apiKey = Read-Host "Paste your $($det.Name) API key (or press Enter to skip)"
$apiKey = Read-Host "Paste your $providerName API key (or press Enter to skip)"
if ($apiKey) {
# Persist as a User-level environment variable (survives reboots)
[System.Environment]::SetEnvironmentVariable($SelectedEnvVar, $apiKey, "User")
# Also set in current session
Set-Item -Path "Env:\$SelectedEnvVar" -Value $apiKey
Write-Host ""
Write-Ok "API key saved as User environment variable: $SelectedEnvVar"
@@ -889,7 +1004,8 @@ if (-not $SelectedProviderId) {
$SelectedProviderId = ""
}
}
} else {
}
9 {
Write-Host ""
Write-Warn "Skipped. An LLM API key is required to test and use worker agents."
Write-Host " Add your API key later by running:"
@@ -901,7 +1017,31 @@ if (-not $SelectedProviderId) {
}
}
# Prompt for model if not already selected
# For ZAI subscription: prompt for API key if not already set
if ($SubscriptionMode -eq "zai_code") {
$existingZai = [System.Environment]::GetEnvironmentVariable("ZAI_API_KEY", "User")
if (-not $existingZai) { $existingZai = $env:ZAI_API_KEY }
if (-not $existingZai) {
Write-Host ""
$apiKey = Read-Host "Paste your ZAI API key (or press Enter to skip)"
if ($apiKey) {
[System.Environment]::SetEnvironmentVariable("ZAI_API_KEY", $apiKey, "User")
$env:ZAI_API_KEY = $apiKey
Write-Host ""
Write-Ok "ZAI API key saved as User environment variable"
} else {
Write-Host ""
Write-Warn "Skipped. Add your ZAI API key later:"
Write-Color -Text " [System.Environment]::SetEnvironmentVariable('ZAI_API_KEY', 'your-key', 'User')" -Color Cyan
$SelectedEnvVar = ""
$SelectedProviderId = ""
$SubscriptionMode = ""
}
}
}
# Prompt for model if not already selected (manual provider path)
if ($SelectedProviderId -and -not $SelectedModel) {
$modelSel = Get-ModelSelection $SelectedProviderId
$SelectedModel = $modelSel.Model
@@ -925,10 +1065,21 @@ if ($SelectedProviderId) {
provider = $SelectedProviderId
model = $SelectedModel
max_tokens = $SelectedMaxTokens
api_key_env_var = $SelectedEnvVar
}
created_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss+00:00")
}
if ($SubscriptionMode -eq "claude_code") {
$config.llm["use_claude_code_subscription"] = $true
} elseif ($SubscriptionMode -eq "codex") {
$config.llm["use_codex_subscription"] = $true
} elseif ($SubscriptionMode -eq "zai_code") {
$config.llm["api_base"] = "https://api.z.ai/api/coding/paas/v4"
$config.llm["api_key_env_var"] = $SelectedEnvVar
} else {
$config.llm["api_key_env_var"] = $SelectedEnvVar
}
$config | ConvertTo-Json -Depth 4 | Set-Content -Path $HiveConfigFile -Encoding UTF8
Write-Ok "done"
Write-Color -Text " ~/.hive/configuration.json" -Color DarkGray
@@ -936,31 +1087,71 @@ if ($SelectedProviderId) {
Write-Host ""
# ============================================================
# Step 6: Initialize Credential Store
# Step 5: Initialize Credential Store
# ============================================================
Write-Step -Number "6" -Text "Step 6: Initializing credential store..."
Write-Step -Number "5" -Text "Step 5: Initializing credential store..."
Write-Color -Text "The credential store encrypts API keys and secrets for your agents." -Color DarkGray
Write-Host ""
$HiveCredDir = Join-Path (Join-Path $env:USERPROFILE ".hive") "credentials"
$HiveKeyFile = Join-Path (Join-Path $env:USERPROFILE ".hive") "secrets\credential_key"
# Check if HIVE_CREDENTIAL_KEY is already set
$credKey = [System.Environment]::GetEnvironmentVariable("HIVE_CREDENTIAL_KEY", "User")
if (-not $credKey) { $credKey = $env:HIVE_CREDENTIAL_KEY }
# Check if HIVE_CREDENTIAL_KEY already exists (from env, file, or User env var)
$credKey = $env:HIVE_CREDENTIAL_KEY
$credKeySource = ""
if ($credKey) {
Write-Ok "HIVE_CREDENTIAL_KEY already set"
$credKeySource = "environment"
} elseif (Test-Path $HiveKeyFile) {
$credKey = (Get-Content $HiveKeyFile -Raw).Trim()
$env:HIVE_CREDENTIAL_KEY = $credKey
$credKeySource = "file"
}
# Backward compat: check User env var (legacy PS1 installs)
if (-not $credKey) {
$credKey = [System.Environment]::GetEnvironmentVariable("HIVE_CREDENTIAL_KEY", "User")
if ($credKey) {
$env:HIVE_CREDENTIAL_KEY = $credKey
$credKeySource = "user_env"
}
}
if ($credKey) {
switch ($credKeySource) {
"environment" { Write-Ok "HIVE_CREDENTIAL_KEY already set" }
"file" { Write-Ok "HIVE_CREDENTIAL_KEY loaded from $HiveKeyFile" }
"user_env" { Write-Ok "HIVE_CREDENTIAL_KEY loaded from User environment variable" }
}
} else {
Write-Host " Generating encryption key... " -NoNewline
try {
$generatedKey = & uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 2>$null
if ($LASTEXITCODE -eq 0 -and $generatedKey) {
Write-Ok "ok"
[System.Environment]::SetEnvironmentVariable("HIVE_CREDENTIAL_KEY", $generatedKey.Trim(), "User")
$env:HIVE_CREDENTIAL_KEY = $generatedKey.Trim()
$credKey = $generatedKey.Trim()
Write-Ok "Encryption key saved as User environment variable"
$generatedKey = $generatedKey.Trim()
# Save to file (matching quickstart.sh behavior)
$secretsDir = Split-Path $HiveKeyFile -Parent
New-Item -ItemType Directory -Path $secretsDir -Force | Out-Null
[System.IO.File]::WriteAllText($HiveKeyFile, $generatedKey)
# Restrict file permissions (best-effort on Windows)
try {
$acl = Get-Acl $HiveKeyFile
$acl.SetAccessRuleProtection($true, $false)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$env:USERNAME, "FullControl", "Allow")
$acl.SetAccessRule($rule)
Set-Acl $HiveKeyFile $acl
} catch {
# Non-critical; file is in user's home directory
}
$env:HIVE_CREDENTIAL_KEY = $generatedKey
$credKey = $generatedKey
Write-Ok "Encryption key saved to $HiveKeyFile"
} else {
Write-Warn "failed"
Write-Warn "Credential store will not be available."
@@ -979,7 +1170,7 @@ if ($credKey) {
$indexFile = Join-Path $credMetaDir "index.json"
if (-not (Test-Path $indexFile)) {
"{}" | Set-Content -Path $indexFile -Encoding UTF8
'{"credentials": {}, "version": "1.0"}' | Set-Content -Path $indexFile -Encoding UTF8
}
Write-Ok "Credential store initialized at ~/.hive/credentials/"
@@ -995,10 +1186,10 @@ if ($credKey) {
Write-Host ""
# ============================================================
# Step 7: Verify Setup
# Step 6: Verify Setup
# ============================================================
Write-Step -Number "7" -Text "Step 7: Verifying installation..."
Write-Step -Number "6" -Text "Step 6: Verifying installation..."
$verifyErrors = 0
@@ -1051,10 +1242,49 @@ if (Test-Path $skillsDir) {
Write-Warn "skipped"
}
Write-Host " $([char]0x2B21) codex CLI... " -NoNewline
$CodexAvailable = $false
$codexVer = ""
$codexCmd = Get-Command codex -ErrorAction SilentlyContinue
if ($codexCmd) {
$codexVersionRaw = & codex --version 2>$null | Select-Object -First 1
if ($codexVersionRaw -match '(\d+)\.(\d+)\.(\d+)') {
$cMajor = [int]$Matches[1]
$cMinor = [int]$Matches[2]
$codexVer = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
if ($cMajor -gt 0 -or ($cMajor -eq 0 -and $cMinor -ge 101)) {
Write-Ok $codexVer
$CodexAvailable = $true
} else {
Write-Warn "$codexVer (upgrade to 0.101.0+)"
}
} else {
Write-Warn "skipped"
}
} else {
Write-Warn "skipped"
}
Write-Host " $([char]0x2B21) local settings... " -NoNewline
$localSettingsPath = Join-Path $ScriptDir ".claude\settings.local.json"
$localSettingsExample = Join-Path $ScriptDir ".claude\settings.local.json.example"
if (Test-Path $localSettingsPath) {
Write-Ok "ok"
} elseif (Test-Path $localSettingsExample) {
Copy-Item $localSettingsExample $localSettingsPath
Write-Ok "copied from example"
} else {
Write-Warn "skipped"
}
Write-Host " $([char]0x2B21) credential store... " -NoNewline
$credStoreDir = Join-Path (Join-Path (Join-Path $env:USERPROFILE ".hive") "credentials") "credentials"
if ($credKey -and (Test-Path $credStoreDir)) { Write-Ok "ok" } else { Write-Warn "skipped" }
Write-Host " $([char]0x2B21) frontend... " -NoNewline
$frontendIndex = Join-Path $ScriptDir "core\frontend\dist\index.html"
if (Test-Path $frontendIndex) { Write-Ok "ok" } else { Write-Warn "skipped" }
Write-Host ""
if ($verifyErrors -gt 0) {
Write-Color -Text "Setup failed with $verifyErrors error(s)." -Color Red
@@ -1063,10 +1293,10 @@ if ($verifyErrors -gt 0) {
}
# ============================================================
# Step 8: Install hive CLI wrapper
# Step 7: Install hive CLI wrapper
# ============================================================
Write-Step -Number "8" -Text "Step 8: Installing hive CLI..."
Write-Step -Number "7" -Text "Step 7: Installing hive CLI..."
# Verify hive.ps1 wrapper exists in project root
$hivePs1Path = Join-Path $ScriptDir "hive.ps1"
@@ -1108,65 +1338,87 @@ Write-Host ""
Write-Host "Your environment is configured for building AI agents."
Write-Host ""
# Show configured provider
if ($SelectedProviderId) {
if (-not $SelectedModel) { $SelectedModel = $DefaultModels[$SelectedProviderId] }
Write-Color -Text "Default LLM:" -Color White
Write-Color -Text " $SelectedProviderId" -Color Cyan -NoNewline
Write-Host " -> " -NoNewline
Write-Color -Text $SelectedModel -Color DarkGray
if ($SubscriptionMode -eq "claude_code") {
Write-Ok "Claude Code Subscription -> $SelectedModel"
Write-Color -Text " Token auto-refresh from ~/.claude/.credentials.json" -Color DarkGray
} elseif ($SubscriptionMode -eq "zai_code") {
Write-Ok "ZAI Code Subscription -> $SelectedModel"
Write-Color -Text " API: api.z.ai (OpenAI-compatible)" -Color DarkGray
} elseif ($SubscriptionMode -eq "codex") {
Write-Ok "OpenAI Codex Subscription -> $SelectedModel"
} else {
Write-Color -Text " $SelectedProviderId" -Color Cyan -NoNewline
Write-Host " -> " -NoNewline
Write-Color -Text $SelectedModel -Color DarkGray
}
Write-Host ""
}
# Show credential store status
if ($credKey) {
Write-Color -Text "Credential Store:" -Color White
Write-Ok "~/.hive/credentials/ (encrypted)"
Write-Color -Text " Set up agent credentials with: /setup-credentials" -Color DarkGray
Write-Host ""
}
Write-Color -Text "Build a New Agent:" -Color White
Write-Host ""
Write-Host " 1. Open Claude Code in this directory:"
Write-Color -Text " claude" -Color Cyan
Write-Host ""
Write-Host " 2. Build a new agent:"
Write-Color -Text " /hive" -Color Cyan
Write-Host ""
Write-Host " 3. Test an existing agent:"
Write-Color -Text " /hive-test" -Color Cyan
Write-Host ""
Write-Color -Text "Run an Agent:" -Color White
Write-Host ""
Write-Host " Launch the interactive dashboard to browse and run agents:"
Write-Host " You can start an example agent or an agent built by yourself:"
Write-Color -Text " .\hive.ps1 tui" -Color Cyan
Write-Host ""
Write-Color -Text "═══════════════════════════════════════════════════════" -Color Yellow
Write-Host ""
Write-Color -Text " IMPORTANT: Restart your terminal now!" -Color Yellow
Write-Host ""
Write-Color -Text "═══════════════════════════════════════════════════════" -Color Yellow
Write-Host ""
Write-Host 'Environment variables (uv, API keys) are now configured, but you need to'
Write-Host 'restart your terminal for them to take effect in new sessions.'
Write-Host ""
Write-Host "After restarting, test with:" -ForegroundColor Cyan
Write-Color -Text " .\hive.ps1 tui" -Color Cyan
Write-Host ""
if ($SelectedProviderId -or $credKey) {
Write-Color -Text "Note:" -Color White
Write-Host "- uv has been added to your User PATH"
if ($SelectedProviderId) {
Write-Host "- $SelectedEnvVar is set for LLM access"
}
if ($credKey) {
Write-Host "- HIVE_CREDENTIAL_KEY is set for credential encryption"
}
Write-Host "- All variables will persist across reboots"
# Show Codex instructions if available
if ($CodexAvailable) {
Write-Color -Text "Build a New Agent (Codex):" -Color White
Write-Host ""
Write-Host " Codex " -NoNewline
Write-Color -Text $codexVer -Color Green -NoNewline
Write-Host " is available. To use it with Hive:"
Write-Host " 1. Restart your terminal (or open a new one)"
Write-Host " 2. Run: " -NoNewline
Write-Color -Text "codex" -Color Cyan
Write-Host " 3. Type: " -NoNewline
Write-Color -Text "use hive" -Color Cyan
Write-Host ""
}
Write-Color -Text 'Run .\quickstart.ps1 again to reconfigure.' -Color DarkGray
Write-Host ""
# Auto-launch dashboard or show manual instructions
if ($FrontendBuilt) {
Write-Color -Text "Launching dashboard..." -Color White
Write-Host ""
Write-Color -Text " Starting server on http://localhost:8787" -Color DarkGray
Write-Color -Text " Press Ctrl+C to stop" -Color DarkGray
Write-Host ""
& (Join-Path $ScriptDir "hive.ps1") serve --open
} else {
Write-Color -Text "═══════════════════════════════════════════════════════" -Color Yellow
Write-Host ""
Write-Color -Text " IMPORTANT: Restart your terminal now!" -Color Yellow
Write-Host ""
Write-Color -Text "═══════════════════════════════════════════════════════" -Color Yellow
Write-Host ""
Write-Host 'Environment variables (uv, API keys) are now configured, but you need to'
Write-Host 'restart your terminal for them to take effect in new sessions.'
Write-Host ""
Write-Color -Text "Run an Agent:" -Color White
Write-Host ""
Write-Host " Launch the interactive dashboard to browse and run agents:"
Write-Host " You can start an example agent or an agent built by yourself:"
Write-Color -Text " .\hive.ps1 tui" -Color Cyan
Write-Host ""
if ($SelectedProviderId -or $credKey) {
Write-Color -Text "Note:" -Color White
Write-Host "- uv has been added to your User PATH"
if ($SelectedProviderId -and $SelectedEnvVar) {
Write-Host "- $SelectedEnvVar is set for LLM access"
}
if ($credKey) {
Write-Host "- HIVE_CREDENTIAL_KEY is set for credential encryption"
}
Write-Host "- All variables will persist across reboots"
Write-Host ""
}
Write-Color -Text 'Run .\quickstart.ps1 again to reconfigure.' -Color DarkGray
Write-Host ""
}
+13 -19
View File
@@ -1044,9 +1044,15 @@ echo ""
HIVE_CRED_DIR="$HOME/.hive/credentials"
# Check if HIVE_CREDENTIAL_KEY already exists (from env or shell rc)
HIVE_KEY_FILE="$HOME/.hive/secrets/credential_key"
# Check if HIVE_CREDENTIAL_KEY already exists (from env, file, or shell rc)
if [ -n "$HIVE_CREDENTIAL_KEY" ]; then
echo -e "${GREEN} ✓ HIVE_CREDENTIAL_KEY already set${NC}"
elif [ -f "$HIVE_KEY_FILE" ]; then
HIVE_CREDENTIAL_KEY=$(cat "$HIVE_KEY_FILE")
export HIVE_CREDENTIAL_KEY
echo -e "${GREEN} ✓ HIVE_CREDENTIAL_KEY loaded from $HIVE_KEY_FILE${NC}"
else
# Generate a new Fernet encryption key
echo -n " Generating encryption key... "
@@ -1059,13 +1065,14 @@ else
else
echo -e "${GREEN}ok${NC}"
# Save to shell rc file
echo "" >> "$SHELL_RC_FILE"
echo "# Encryption key for Hive credential store (~/.hive/credentials)" >> "$SHELL_RC_FILE"
echo "export HIVE_CREDENTIAL_KEY=\"$GENERATED_KEY\"" >> "$SHELL_RC_FILE"
# Save to dedicated secrets file (chmod 600)
mkdir -p "$(dirname "$HIVE_KEY_FILE")"
chmod 700 "$(dirname "$HIVE_KEY_FILE")"
echo -n "$GENERATED_KEY" > "$HIVE_KEY_FILE"
chmod 600 "$HIVE_KEY_FILE"
export HIVE_CREDENTIAL_KEY="$GENERATED_KEY"
echo -e "${GREEN} ✓ Encryption key saved to $SHELL_RC_FILE${NC}"
echo -e "${GREEN} ✓ Encryption key saved to $HIVE_KEY_FILE${NC}"
fi
fi
@@ -1263,22 +1270,9 @@ fi
if [ -n "$HIVE_CREDENTIAL_KEY" ]; then
echo -e "${BOLD}Credential Store:${NC}"
echo -e " ${GREEN}${NC} ${DIM}~/.hive/credentials/${NC} (encrypted)"
echo -e " ${DIM}Set up agent credentials with:${NC} ${CYAN}/setup-credentials${NC}"
echo ""
fi
echo -e "${BOLD}Build a New Agent (Claude):${NC}"
echo ""
echo -e " 1. Open Claude Code in this directory:"
echo -e " ${CYAN}claude${NC}"
echo ""
echo -e " 2. Build a new agent:"
echo -e " ${CYAN}/hive${NC}"
echo ""
echo -e " 3. Test an existing agent:"
echo -e " ${CYAN}/hive-test${NC}"
echo ""
# Show Codex instructions if available
if [ "$CODEX_AVAILABLE" = true ]; then
echo -e "${BOLD}Build a New Agent (Codex):${NC}"
+1 -2
View File
@@ -36,8 +36,7 @@ EMAIL_CREDENTIALS = {
"google": CredentialSpec(
env_var="GOOGLE_ACCESS_TOKEN",
tools=[
# send_email is excluded: it's a multi-provider tool that checks
# credentials at runtime based on the provider parameter.
"send_email",
"gmail_reply_email",
"gmail_list_messages",
"gmail_get_message",