Merge pull request #5499 from aden-hive/feat/open-hive
Release / Create Release (push) Waiting to run
Release / Create Release (push) Waiting to run
feat: tool call revamp, Intercom & GA integrations, credential improvements
This commit is contained in:
@@ -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>")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -87,6 +87,11 @@
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user