Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65c8e1653c | |||
| 58e4fa918c | |||
| d2eb86e534 | |||
| 23a7b080eb | |||
| bf39bcdec9 | |||
| 0276632491 | |||
| ae2993d0d1 | |||
| d14d71f760 | |||
| ef6efc2f55 | |||
| 738641d35f | |||
| 22f5534f08 | |||
| b79e7eca73 | |||
| 28250dc45e | |||
| fe5df6a87a | |||
| 07e4b593dd | |||
| 497591bf3b | |||
| a2a3e334d6 | |||
| 1ccbfaf800 | |||
| a9afa0555c | |||
| 83b2183cf0 | |||
| c2dea88398 | |||
| f49e7a760e | |||
| dc95c88da0 | |||
| 6e0255ebec | |||
| b51e688d1a | |||
| 379d3df46b | |||
| b77a3031fe | |||
| c10eea04ec | |||
| 491a3f24da | |||
| c7d70e0fb1 | |||
| d59f8e99cb | |||
| 0a91b49417 | |||
| ced64541b9 | |||
| 3c30cfe02b | |||
| 0d6267bcf1 | |||
| b47175d1df | |||
| 6f23a30eed | |||
| ff7b5c7e27 | |||
| 69f0ff7ac9 | |||
| c3f13c50eb | |||
| 5477408d40 | |||
| 9fad385ddf | |||
| cf44ee1d9b | |||
| 4ab33a39d6 | |||
| ae19121802 | |||
| b518525418 | |||
| ac3fe38b33 | |||
| 3c6a30fcae | |||
| 2ced873fb5 | |||
| ab995d8b96 | |||
| c2e560fc07 | |||
| 19f7ae862e | |||
| 5e9f74744a | |||
| 7787179a5a | |||
| b63205b91a | |||
| 347bccb9ee | |||
| 9d83f0298f | |||
| 7f7e8b4dff | |||
| f48a7380f5 | |||
| 3c7f129d86 | |||
| 4533b27aa1 | |||
| 3adf268c29 | |||
| ac8579900f | |||
| abbaaa68f3 | |||
| 11089093ef | |||
| 99b7cb07d5 | |||
| 70d61ae67a | |||
| dd054815a3 | |||
| 8e5eaae9dd | |||
| 2d0128eb5c | |||
| 06f1d4dcef | |||
| 0e7b11b5b2 | |||
| 291b78f934 | |||
| e196a03972 | |||
| a0abe2685d | |||
| e8f642c8b6 | |||
| 6260f628eb | |||
| 4a4f17ed40 | |||
| 36dcf2025b | |||
| 85c70c94e6 | |||
| 336e82ba22 | |||
| f2ddd1051d | |||
| 2dd60c8d52 | |||
| ff01c1fd99 | |||
| 421b25fdb7 | |||
| 795c3c33e2 | |||
| 97821f4d80 | |||
| 505e1e30fd | |||
| 3fb2b285fb | |||
| a76109840c | |||
| 1db8484402 | |||
| 39212350ba | |||
| 7ede3ba171 | |||
| cdaec8a837 | |||
| 635d2976f4 | |||
| 4e1525880d | |||
| 20427e213a | |||
| 094ba89f19 | |||
| 7008c9f310 | |||
| 94d7cbacc2 | |||
| bddc2b413a | |||
| 48c8fb7fff | |||
| bc3c5a5899 | |||
| e82133741c | |||
| 5076278dcb | |||
| 2398e04e11 | |||
| d00f321627 | |||
| e76b6cb575 |
+150
-27
@@ -1,17 +1,149 @@
|
||||
# Release Notes
|
||||
|
||||
## v0.7.1
|
||||
|
||||
**Release Date:** March 13, 2026
|
||||
**Tag:** v0.7.1
|
||||
|
||||
### Chrome-Native Browser Control
|
||||
|
||||
v0.7.1 replaces Playwright with direct Chrome DevTools Protocol (CDP) integration. The GCU now launches the user's system Chrome via `open -n` on macOS, connects over CDP, and manages browser lifecycle end-to-end -- no extra browser binary required.
|
||||
|
||||
---
|
||||
|
||||
### Highlights
|
||||
|
||||
#### System Chrome via CDP
|
||||
|
||||
The entire GCU browser stack has been rewritten:
|
||||
|
||||
- **Chrome finder & launcher** -- New `chrome_finder.py` discovers installed Chrome and `chrome_launcher.py` manages process lifecycle with `--remote-debugging-port`
|
||||
- **Coexist with user's browser** -- `open -n` on macOS launches a separate Chrome instance so the user's tabs stay untouched
|
||||
- **Dynamic viewport sizing** -- Viewport auto-sizes to the available display area, suppressing Chrome warning bars
|
||||
- **Orphan cleanup** -- Chrome processes are killed on GCU server shutdown to prevent leaks
|
||||
- **`--no-startup-window`** -- Chrome launches headlessly by default until a page is needed
|
||||
|
||||
#### Per-Subagent Browser Isolation
|
||||
|
||||
Each GCU subagent gets its own Chrome user-data directory, preventing cookie/session cross-contamination:
|
||||
|
||||
- Unique browser profiles injected per subagent
|
||||
- Profiles cleaned up after top-level GCU node execution
|
||||
- Tab origin and age metadata tracked per subagent
|
||||
|
||||
#### Dummy Agent Testing Framework
|
||||
|
||||
A comprehensive test suite for validating agent graph patterns without LLM calls:
|
||||
|
||||
- 8 test modules covering echo, pipeline, branch, parallel merge, retry, feedback loop, worker, and GCU subagent patterns
|
||||
- Shared fixtures and a `run_all.py` runner for CI integration
|
||||
- Subagent lifecycle tests
|
||||
|
||||
---
|
||||
|
||||
### What's New
|
||||
|
||||
#### GCU Browser
|
||||
|
||||
- **Switch from Playwright to system Chrome via CDP** -- Direct CDP connection replaces Playwright dependency. (@bryanadenhq)
|
||||
- **Chrome finder and launcher modules** -- `chrome_finder.py` and `chrome_launcher.py` for cross-platform Chrome discovery and process management. (@bryanadenhq)
|
||||
- **Dynamic viewport sizing** -- Auto-size viewport and suppress Chrome warning bar. (@bryanadenhq)
|
||||
- **Per-subagent browser profile isolation** -- Unique user-data directories per subagent with cleanup. (@bryanadenhq)
|
||||
- **Tab origin/age metadata** -- Track which subagent opened each tab and when. (@bryanadenhq)
|
||||
- **`browser_close_all` tool** -- Bulk tab cleanup for agents managing many pages. (@bryanadenhq)
|
||||
- **Auto-track popup pages** -- Popups are automatically captured and tracked. (@bryanadenhq)
|
||||
- **Auto-snapshot from browser interactions** -- Browser interaction tools return screenshots automatically. (@bryanadenhq)
|
||||
- **Kill orphaned Chrome processes** -- GCU server shutdown cleans up lingering Chrome instances. (@bryanadenhq)
|
||||
- **`--no-startup-window` Chrome flag** -- Prevent empty window on launch. (@bryanadenhq)
|
||||
- **Launch Chrome via `open -n` on macOS** -- Coexist with the user's running browser. (@bryanadenhq)
|
||||
|
||||
#### Framework & Runtime
|
||||
|
||||
- **Session resume fix for new agents** -- Correctly resume sessions when a new agent is loaded. (@bryanadenhq)
|
||||
- **Queen upsert fix** -- Prevent duplicate queen entries on session restore. (@bryanadenhq)
|
||||
- **Anchor worker monitoring to queen's session ID on cold-restore** -- Worker monitors reconnect to the correct queen after restart. (@bryanadenhq)
|
||||
- **Update meta.json when loading workers** -- Worker metadata stays in sync with runtime state. (@RichardTang-Aden)
|
||||
- **Generate worker MCP file correctly** -- Fix MCP config generation for spawned workers. (@RichardTang-Aden)
|
||||
- **Share event bus so tool events are visible to parent** -- Tool execution events propagate up to parent graphs. (@bryanadenhq)
|
||||
- **Subagent activity tracking in queen status** -- Queen instructions include live subagent status. (@bryanadenhq)
|
||||
- **GCU system prompt updates** -- Auto-snapshots, batching, popup tracking, and close_all guidance. (@bryanadenhq)
|
||||
|
||||
#### Frontend
|
||||
|
||||
- **Loading spinner in draft panel** -- Shows spinner during planning phase instead of blank panel. (@bryanadenhq)
|
||||
- **Fix credential modal errors** -- Modal no longer eats errors; banner stays visible. (@bryanadenhq)
|
||||
- **Fix credentials_required loop** -- Stop clearing the flag on modal close to prevent infinite re-prompting. (@bryanadenhq)
|
||||
- **Fix "Add tab" dropdown overflow** -- Dropdown no longer hidden when many agents are open. (@prasoonmhwr)
|
||||
|
||||
#### Testing
|
||||
|
||||
- **Dummy agent test framework** -- 8 test modules (echo, pipeline, branch, parallel merge, retry, feedback loop, worker, GCU subagent) with shared fixtures and CI runner. (@bryanadenhq)
|
||||
- **Subagent lifecycle tests** -- Validate subagent spawn and completion flows. (@bryanadenhq)
|
||||
|
||||
#### Documentation & Infrastructure
|
||||
|
||||
- **MCP integration PRD** -- Product requirements for MCP server registry. (@TimothyZhang7)
|
||||
- **Skills registry PRD** -- Product requirements for skill registry system. (@bryanadenhq)
|
||||
- **Bounty program updates** -- Standard bounty issue template and updated contributor guide. (@bryanadenhq)
|
||||
- **Windows quickstart** -- Add default context limit for PowerShell setup. (@bryanadenhq)
|
||||
- **Remove deprecated files** -- Clean up `setup_mcp.py`, `verify_mcp.py`, `antigravity-setup.md`, and `setup-antigravity-mcp.sh`. (@bryanadenhq)
|
||||
|
||||
---
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix credential modal eating errors and banner staying open
|
||||
- Stop clearing `credentials_required` on modal close to prevent infinite loop
|
||||
- Share event bus so tool events are visible to parent graph
|
||||
- Use lazy %-formatting in subagent completion log to avoid f-string in logger
|
||||
- Anchor worker monitoring to queen's session ID on cold-restore
|
||||
- Update meta.json when loading workers
|
||||
- Generate worker MCP file correctly
|
||||
- Fix "Add tab" dropdown partially hidden when creating multiple agents
|
||||
|
||||
---
|
||||
|
||||
### Community Contributors
|
||||
|
||||
- **Prasoon Mahawar** (@prasoonmhwr) -- Fix UI overflow on agent tab dropdown
|
||||
- **Richard Tang** (@RichardTang-Aden) -- Worker MCP generation and meta.json fixes
|
||||
|
||||
---
|
||||
|
||||
### Upgrading
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
uv sync
|
||||
```
|
||||
|
||||
The Playwright dependency is no longer required for GCU browser operations. Chrome must be installed on the host system.
|
||||
|
||||
---
|
||||
|
||||
## v0.7.0
|
||||
|
||||
**Release Date:** March 5, 2026
|
||||
**Tag:** v0.7.0
|
||||
|
||||
Session management refactor release.
|
||||
|
||||
---
|
||||
|
||||
## v0.5.1
|
||||
|
||||
**Release Date:** February 18, 2026
|
||||
**Tag:** v0.5.1
|
||||
|
||||
## The Hive Gets a Brain
|
||||
### The Hive Gets a Brain
|
||||
|
||||
v0.5.1 is our most ambitious release yet. Hive agents can now **build other agents** -- the new Hive Coder meta-agent writes, tests, and fixes agent packages from natural language. The runtime grows multi-graph support so one session can orchestrate multiple agents simultaneously. The TUI gets a complete overhaul with an in-app agent picker, live streaming, and seamless escalation to the Coder. And we're now provider-agnostic: Claude Code subscriptions, OpenAI-compatible endpoints, and any LiteLLM-supported model work out of the box.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
### Highlights
|
||||
|
||||
### Hive Coder -- The Agent That Builds Agents
|
||||
#### Hive Coder -- The Agent That Builds Agents
|
||||
|
||||
A native meta-agent that lives inside the framework at `core/framework/agents/hive_coder/`. Give it a natural-language specification and it produces a complete agent package -- goal definition, node prompts, edge routing, MCP tool wiring, tests, and all boilerplate files.
|
||||
|
||||
@@ -30,7 +162,7 @@ The Coder ships with:
|
||||
- **Coder Tools MCP server** -- file I/O, fuzzy-match editing, git snapshots, and sandboxed shell execution (`tools/coder_tools_server.py`)
|
||||
- **Test generation** -- structural tests for forever-alive agents that don't hang on `runner.run()`
|
||||
|
||||
### Multi-Graph Agent Runtime
|
||||
#### Multi-Graph Agent Runtime
|
||||
|
||||
`AgentRuntime` now supports loading, managing, and switching between multiple agent graphs within a single session. Six new lifecycle tools give agents (and the TUI) full control:
|
||||
|
||||
@@ -44,7 +176,7 @@ await runtime.add_graph("exports/deep_research_agent")
|
||||
|
||||
The Hive Coder uses multi-graph internally -- when you escalate from a worker agent, the Coder loads as a separate graph while the worker stays alive in the background.
|
||||
|
||||
### TUI Revamp
|
||||
#### TUI Revamp
|
||||
|
||||
The Terminal UI gets a ground-up rebuild with five major additions:
|
||||
|
||||
@@ -54,7 +186,7 @@ The Terminal UI gets a ground-up rebuild with five major additions:
|
||||
- **PDF attachments** -- `/attach` and `/detach` commands with native OS file dialog (macOS, Linux, Windows)
|
||||
- **Multi-graph commands** -- `/graphs`, `/graph <id>`, `/load <path>`, `/unload <id>` for managing agent graphs in-session
|
||||
|
||||
### Provider-Agnostic LLM Support
|
||||
#### Provider-Agnostic LLM Support
|
||||
|
||||
Hive is no longer Anthropic-only. v0.5.1 adds first-class support for:
|
||||
|
||||
@@ -66,9 +198,9 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
|
||||
|
||||
---
|
||||
|
||||
## What's New
|
||||
### What's New
|
||||
|
||||
### Architecture & Runtime
|
||||
#### Architecture & Runtime
|
||||
|
||||
- **Hive Coder meta-agent** -- Natural-language agent builder with reference docs, guardian watchdog, and `hive code` CLI command. (@TimothyZhang7)
|
||||
- **Multi-graph agent sessions** -- `add_graph`/`remove_graph` on AgentRuntime with 6 lifecycle tools (`load_agent`, `unload_agent`, `start_agent`, `restart_agent`, `list_agents`, `get_user_presence`). (@TimothyZhang7)
|
||||
@@ -79,7 +211,7 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
|
||||
- **Pre-start confirmation prompt** -- Interactive prompt before agent execution allowing credential updates or abort. (@RichardTang-Aden)
|
||||
- **Event bus multi-graph support** -- `graph_id` on events, `filter_graph` on subscriptions, `ESCALATION_REQUESTED` event type, `exclude_own_graph` filter. (@TimothyZhang7)
|
||||
|
||||
### TUI Improvements
|
||||
#### TUI Improvements
|
||||
|
||||
- **In-app agent picker** (Ctrl+A) -- Tabbed modal for browsing agents with metadata badges (nodes, tools, sessions, tags). (@TimothyZhang7)
|
||||
- **Runtime-optional TUI startup** -- Launches without a pre-loaded agent, shows agent picker on startup. (@TimothyZhang7)
|
||||
@@ -89,7 +221,7 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
|
||||
- **Multi-graph TUI commands** -- `/graphs`, `/graph <id>`, `/load <path>`, `/unload <id>`. (@TimothyZhang7)
|
||||
- **Agent Guardian watchdog** -- Event-driven monitor that catches secondary agent failures and triggers automatic remediation, with `--no-guardian` CLI flag. (@TimothyZhang7)
|
||||
|
||||
### New Tool Integrations
|
||||
#### New Tool Integrations
|
||||
|
||||
| Tool | Description | Contributor |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
@@ -99,7 +231,7 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
|
||||
| **Google Docs** | Document creation, reading, and editing with OAuth credential support | @haliaeetusvocifer |
|
||||
| **Gmail enhancements** | Expanded mail operations for inbox management | @bryanadenhq |
|
||||
|
||||
### Infrastructure
|
||||
#### Infrastructure
|
||||
|
||||
- **Default node type → `event_loop`** -- `NodeSpec.node_type` defaults to `"event_loop"` instead of `"llm_tool_use"`. (@TimothyZhang7)
|
||||
- **Default `max_node_visits` → 0 (unlimited)** -- Nodes default to unlimited visits, reducing friction for feedback loops and forever-alive agents. (@TimothyZhang7)
|
||||
@@ -112,7 +244,7 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
### Bug Fixes
|
||||
|
||||
- Flush WIP accumulator outputs on cancel/failure so edge conditions see correct values on resume
|
||||
- Stall detection state preserved across resume (no more resets on checkpoint restore)
|
||||
@@ -125,13 +257,13 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
|
||||
- Fix email agent version conflicts (@RichardTang-Aden)
|
||||
- Fix coder tool timeouts (120s for tests, 300s cap for commands)
|
||||
|
||||
## Documentation
|
||||
### Documentation
|
||||
|
||||
- Clarify installation and prevent root pip install misuse (@paarths-collab)
|
||||
|
||||
---
|
||||
|
||||
## Agent Updates
|
||||
### Agent Updates
|
||||
|
||||
- **Email Inbox Management** -- Consolidate `gmail_inbox_guardian` and `inbox_management` into a single unified agent with updated prompts and config. (@RichardTang-Aden, @bryanadenhq)
|
||||
- **Job Hunter** -- Updated node prompts, config, and agent metadata; added PDF resume selection. (@bryanadenhq)
|
||||
@@ -141,7 +273,7 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
|
||||
|
||||
---
|
||||
|
||||
## Breaking Changes
|
||||
### Breaking Changes
|
||||
|
||||
- **Deprecated node types raise `RuntimeError`** -- `llm_tool_use`, `llm_generate`, `function`, `router`, `human_input` now fail instead of warning. Migrate to `event_loop`.
|
||||
- **`NodeSpec.node_type` defaults to `"event_loop"`** (was `"llm_tool_use"`)
|
||||
@@ -150,7 +282,7 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
|
||||
|
||||
---
|
||||
|
||||
## Community Contributors
|
||||
### Community Contributors
|
||||
|
||||
A huge thank you to everyone who contributed to this release:
|
||||
|
||||
@@ -165,14 +297,14 @@ A huge thank you to everyone who contributed to this release:
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
### Upgrading
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Migration Guide
|
||||
#### Migration Guide
|
||||
|
||||
If your agents use deprecated node types, update them:
|
||||
|
||||
@@ -196,12 +328,3 @@ hive code
|
||||
# Or from TUI -- press Ctrl+E to escalate
|
||||
hive tui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
- **Agent-to-agent communication** -- one agent's output triggers another agent's entry point
|
||||
- **Cost visibility** -- detailed runtime log of LLM costs per node and per session
|
||||
- **Persistent webhook subscriptions** -- survive agent restarts without re-registering
|
||||
- **Remote agent deployment** -- run agents as long-lived services with HTTP APIs
|
||||
|
||||
+8
-3
@@ -121,9 +121,15 @@ uv sync
|
||||
6. Make your changes
|
||||
7. Run checks and tests:
|
||||
```bash
|
||||
make check # Lint and format checks (ruff check + ruff format --check)
|
||||
make check # Lint and format checks
|
||||
make test # Core tests
|
||||
```
|
||||
On Windows (no make), run directly:
|
||||
```powershell
|
||||
uv run ruff check core/ tools/
|
||||
uv run ruff format --check core/ tools/
|
||||
uv run pytest core/tests/
|
||||
```
|
||||
8. Commit your changes following our commit conventions
|
||||
9. Push to your fork and submit a Pull Request
|
||||
|
||||
@@ -222,8 +228,7 @@ else: # linux
|
||||
- **Node.js 18+** (optional, for frontend development)
|
||||
|
||||
> **Windows Users:**
|
||||
> If you are on native Windows, it is recommended to use **WSL (Windows Subsystem for Linux)**.
|
||||
> Alternatively, make sure to run PowerShell or Git Bash with Python 3.11+ installed, and disable "App Execution Aliases" in Windows settings.
|
||||
> Native Windows is supported. Use `.\quickstart.ps1` for setup and `.\hive.ps1` to run (PowerShell 5.1+). Disable "App Execution Aliases" in Windows settings to avoid Python path conflicts. WSL is also an option but not required.
|
||||
|
||||
> **Tip:** Installing Claude Code skills is optional for running existing agents, but required if you plan to **build new agents**.
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
.PHONY: lint format check test install-hooks help frontend-install frontend-dev frontend-build
|
||||
.PHONY: lint format check test test-tools test-live test-all install-hooks help frontend-install frontend-dev frontend-build
|
||||
|
||||
# ── Ensure uv is findable in Git Bash on Windows ──────────────────────────────
|
||||
# uv installs to ~/.local/bin on Windows/Linux/macOS. Git Bash may not include
|
||||
# this in PATH by default, so we prepend it here.
|
||||
export PATH := $(HOME)/.local/bin:$(PATH)
|
||||
|
||||
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
@@ -46,4 +53,4 @@ frontend-dev: ## Start frontend dev server
|
||||
cd core/frontend && npm run dev
|
||||
|
||||
frontend-build: ## Build frontend for production
|
||||
cd core/frontend && npm run build
|
||||
cd core/frontend && npm run build
|
||||
@@ -27,7 +27,7 @@
|
||||
<img src="https://img.shields.io/badge/Multi--Agent-Systems-blue?style=flat-square" alt="Multi-Agent" />
|
||||
<img src="https://img.shields.io/badge/Headless-Development-purple?style=flat-square" alt="Headless" />
|
||||
<img src="https://img.shields.io/badge/Human--in--the--Loop-orange?style=flat-square" alt="HITL" />
|
||||
<img src="https://img.shields.io/badge/Production--Ready-red?style=flat-square" alt="Production" />
|
||||
<img src="https://img.shields.io/badge/Browser-Use-red?style=flat-square" alt="Browser Use" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/OpenAI-supported-412991?style=flat-square&logo=openai" alt="OpenAI" />
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Build autonomous, reliable, self-improving AI agents without hardcoding workflows. Define your goal through conversation with hive coding agent(queen), and the framework generates a node graph with dynamically created connection code. When things break, the framework captures failure data, evolves the agent through the coding agent, and redeploys. Built-in human-in-the-loop nodes, credential management, and real-time monitoring give you control without sacrificing adaptability.
|
||||
Generate a swarm of worker agents with a coding agent(queen) that control them. Define your goal through conversation with hive queen, and the framework generates a node graph with dynamically created connection code. When things break, the framework captures failure data, evolves the agent through the coding agent, and redeploys. Built-in human-in-the-loop nodes, browser use, credential management, and real-time monitoring give you control without sacrificing adaptability.
|
||||
|
||||
Visit [adenhq.com](https://adenhq.com) for complete documentation, examples, and guides.
|
||||
|
||||
@@ -45,7 +45,7 @@ Visit [adenhq.com](https://adenhq.com) for complete documentation, examples, and
|
||||
|
||||
## Who Is Hive For?
|
||||
|
||||
Hive is designed for developers and teams who want to build **production-grade AI agents** without manually wiring complex workflows.
|
||||
Hive is designed for developers and teams who want to build many **autonomous AI agents** fast without manually wiring complex workflows.
|
||||
|
||||
Hive is a good fit if you:
|
||||
|
||||
@@ -84,7 +84,7 @@ Use Hive when you need:
|
||||
- An LLM provider that powers the agents
|
||||
- **ripgrep (optional, recommended on Windows):** The `search_files` tool uses ripgrep for faster file search. If not installed, a Python fallback is used. On Windows: `winget install BurntSushi.ripgrep` or `scoop install ripgrep`
|
||||
|
||||
> **Note for Windows Users:** It is strongly recommended to use **WSL (Windows Subsystem for Linux)** or **Git Bash** to run this framework. Some core automation scripts may not execute correctly in standard Command Prompt or PowerShell.
|
||||
> **Windows Users:** Native Windows is supported via `quickstart.ps1` and `hive.ps1`. Run these in PowerShell 5.1+. WSL is also an option but not required.
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -115,11 +115,9 @@ This sets up:
|
||||
|
||||
> **Tip:** To reopen the dashboard later, run `hive open` from the project directory.
|
||||
|
||||
<img width="2500" height="1214" alt="home-screen" src="https://github.com/user-attachments/assets/134d897f-5e75-4874-b00b-e0505f6b45c4" />
|
||||
|
||||
### Build Your First Agent
|
||||
|
||||
Type the agent you want to build in the home input box
|
||||
Type the agent you want to build in the home input box. The queen is going to ask you questions and work out a solution with you.
|
||||
|
||||
<img width="2500" height="1214" alt="Image" src="https://github.com/user-attachments/assets/1ce19141-a78b-46f5-8d64-dbf987e048f4" />
|
||||
|
||||
@@ -131,7 +129,7 @@ Click "Try a sample agent" and check the templates. You can run a template direc
|
||||
|
||||
Now you can run an agent by selecting the agent (either an existing agent or example agent). You can click the Run button on the top left, or talk to the queen agent and it can run the agent for you.
|
||||
|
||||
<img width="2500" height="1214" alt="Image" src="https://github.com/user-attachments/assets/71c38206-2ad5-49aa-bde8-6698d0bc55f5" />
|
||||
<img width="2549" height="1174" alt="Screenshot 2026-03-12 at 9 27 36 PM" src="https://github.com/user-attachments/assets/7c7d30fa-9ceb-4c23-95af-b1caa405547d" />
|
||||
|
||||
## Features
|
||||
|
||||
@@ -143,7 +141,6 @@ Now you can run an agent by selecting the agent (either an existing agent or exa
|
||||
- **SDK-Wrapped Nodes** - Every node gets shared memory, local RLM memory, monitoring, tools, and LLM access out of the box
|
||||
- **[Human-in-the-Loop](docs/key_concepts/graph.md#human-in-the-loop)** - Intervention nodes that pause execution for human input with configurable timeouts and escalation
|
||||
- **Real-time Observability** - WebSocket streaming for live monitoring of agent execution, decisions, and node-to-node communication
|
||||
- **Production-Ready** - Self-hostable, built for scale and reliability
|
||||
|
||||
## Integration
|
||||
|
||||
@@ -392,10 +389,6 @@ Hive generates your entire agent system from natural language goals using a codi
|
||||
|
||||
Yes, Hive is fully open-source under the Apache License 2.0. We actively encourage community contributions and collaboration.
|
||||
|
||||
**Q: Can Hive handle complex, production-scale use cases?**
|
||||
|
||||
Yes. Hive is explicitly designed for production environments with features like automatic failure recovery, real-time observability, cost controls, and horizontal scaling support. The framework handles both simple automations and complex multi-agent workflows.
|
||||
|
||||
**Q: Does Hive support human-in-the-loop workflows?**
|
||||
|
||||
Yes, Hive fully supports [human-in-the-loop](docs/key_concepts/graph.md#human-in-the-loop) workflows through intervention nodes that pause execution for human input. These include configurable timeouts and escalation policies, allowing seamless collaboration between human experts and AI agents.
|
||||
@@ -420,6 +413,16 @@ Visit [docs.adenhq.com](https://docs.adenhq.com/) for complete guides, API refer
|
||||
|
||||
Contributions are welcome! Fork the repository, create your feature branch, implement your changes, and submit a pull request. See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#aden-hive/hive&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=aden-hive/hive&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=aden-hive/hive&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=aden-hive/hive&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
EventLoopNode WebSocket Demo
|
||||
|
||||
Real LLM, real FileConversationStore, real EventBus.
|
||||
Streams EventLoopNode execution to a browser via WebSocket.
|
||||
|
||||
Usage:
|
||||
cd /home/timothy/oss/hive/core
|
||||
python demos/event_loop_wss_demo.py
|
||||
|
||||
Then open http://localhost:8765 in your browser.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import tempfile
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from bs4 import BeautifulSoup
|
||||
from websockets.http11 import Request, Response
|
||||
|
||||
# Add core, tools, and hive root to path
|
||||
_CORE_DIR = Path(__file__).resolve().parent.parent
|
||||
_HIVE_DIR = _CORE_DIR.parent
|
||||
sys.path.insert(0, str(_CORE_DIR)) # framework.*
|
||||
sys.path.insert(0, str(_HIVE_DIR / "tools" / "src")) # aden_tools.*
|
||||
sys.path.insert(0, str(_HIVE_DIR)) # core.framework.* (for aden_tools imports)
|
||||
|
||||
import os # noqa: E402
|
||||
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS, CredentialStoreAdapter # noqa: E402
|
||||
from core.framework.credentials import CredentialStore # noqa: E402
|
||||
|
||||
from framework.credentials.storage import ( # noqa: E402
|
||||
CompositeStorage,
|
||||
EncryptedFileStorage,
|
||||
EnvVarStorage,
|
||||
)
|
||||
from framework.graph.event_loop_node import EventLoopNode, LoopConfig # noqa: E402
|
||||
from framework.graph.node import NodeContext, NodeSpec, SharedMemory # noqa: E402
|
||||
from framework.llm.litellm import LiteLLMProvider # noqa: E402
|
||||
from framework.llm.provider import Tool # noqa: E402
|
||||
from framework.runner.tool_registry import ToolRegistry # noqa: E402
|
||||
from framework.runtime.core import Runtime # noqa: E402
|
||||
from framework.runtime.event_bus import EventBus, EventType # noqa: E402
|
||||
from framework.storage.conversation_store import FileConversationStore # noqa: E402
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
|
||||
logger = logging.getLogger("demo")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Persistent state (shared across WebSocket connections)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
STORE_DIR = Path(tempfile.mkdtemp(prefix="hive_demo_"))
|
||||
STORE = FileConversationStore(STORE_DIR / "conversation")
|
||||
RUNTIME = Runtime(STORE_DIR / "runtime")
|
||||
LLM = LiteLLMProvider(model="claude-sonnet-4-5-20250929")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool Registry — real tools via ToolRegistry (same pattern as GraphExecutor)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
TOOL_REGISTRY = ToolRegistry()
|
||||
|
||||
# Credential store: Aden sync (OAuth2 tokens) + encrypted files + env var fallback
|
||||
_env_mapping = {name: spec.env_var for name, spec in CREDENTIAL_SPECS.items()}
|
||||
_local_storage = CompositeStorage(
|
||||
primary=EncryptedFileStorage(),
|
||||
fallbacks=[EnvVarStorage(env_mapping=_env_mapping)],
|
||||
)
|
||||
|
||||
if os.environ.get("ADEN_API_KEY"):
|
||||
try:
|
||||
from framework.credentials.aden import ( # noqa: E402
|
||||
AdenCachedStorage,
|
||||
AdenClientConfig,
|
||||
AdenCredentialClient,
|
||||
AdenSyncProvider,
|
||||
)
|
||||
|
||||
_client = AdenCredentialClient(AdenClientConfig(base_url="https://api.adenhq.com"))
|
||||
_provider = AdenSyncProvider(client=_client)
|
||||
_storage = AdenCachedStorage(
|
||||
local_storage=_local_storage,
|
||||
aden_provider=_provider,
|
||||
)
|
||||
_cred_store = CredentialStore(storage=_storage, providers=[_provider], auto_refresh=True)
|
||||
_synced = _provider.sync_all(_cred_store)
|
||||
logger.info("Synced %d credentials from Aden", _synced)
|
||||
except Exception as e:
|
||||
logger.warning("Aden sync unavailable: %s", e)
|
||||
_cred_store = CredentialStore(storage=_local_storage)
|
||||
else:
|
||||
logger.info("ADEN_API_KEY not set, using local credential storage")
|
||||
_cred_store = CredentialStore(storage=_local_storage)
|
||||
|
||||
CREDENTIALS = CredentialStoreAdapter(_cred_store)
|
||||
|
||||
# Debug: log which credentials resolved
|
||||
for _name in ["brave_search", "hubspot", "anthropic"]:
|
||||
_val = CREDENTIALS.get(_name)
|
||||
if _val:
|
||||
logger.debug("credential %s: OK (len=%d)", _name, len(_val))
|
||||
else:
|
||||
logger.debug("credential %s: not found", _name)
|
||||
|
||||
# --- web_search (Brave Search API) ---
|
||||
|
||||
TOOL_REGISTRY.register(
|
||||
name="web_search",
|
||||
tool=Tool(
|
||||
name="web_search",
|
||||
description=(
|
||||
"Search the web for current information. "
|
||||
"Returns titles, URLs, and snippets from search results."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query (1-500 characters)",
|
||||
},
|
||||
"num_results": {
|
||||
"type": "integer",
|
||||
"description": "Number of results to return (1-20, default 10)",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
executor=lambda inputs: _exec_web_search(inputs),
|
||||
)
|
||||
|
||||
|
||||
def _exec_web_search(inputs: dict) -> dict:
|
||||
api_key = CREDENTIALS.get("brave_search")
|
||||
if not api_key:
|
||||
return {"error": "brave_search credential not configured"}
|
||||
query = inputs.get("query", "")
|
||||
num_results = min(inputs.get("num_results", 10), 20)
|
||||
resp = httpx.get(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
params={"q": query, "count": num_results},
|
||||
headers={"X-Subscription-Token": api_key, "Accept": "application/json"},
|
||||
timeout=30.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"Brave API HTTP {resp.status_code}"}
|
||||
data = resp.json()
|
||||
results = [
|
||||
{
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"snippet": item.get("description", ""),
|
||||
}
|
||||
for item in data.get("web", {}).get("results", [])[:num_results]
|
||||
]
|
||||
return {"query": query, "results": results, "total": len(results)}
|
||||
|
||||
|
||||
# --- web_scrape (httpx + BeautifulSoup, no playwright for sync compat) ---
|
||||
|
||||
TOOL_REGISTRY.register(
|
||||
name="web_scrape",
|
||||
tool=Tool(
|
||||
name="web_scrape",
|
||||
description=(
|
||||
"Scrape and extract text content from a webpage URL. "
|
||||
"Returns the page title and main text content."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL of the webpage to scrape",
|
||||
},
|
||||
"max_length": {
|
||||
"type": "integer",
|
||||
"description": "Maximum text length (default 50000)",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
),
|
||||
executor=lambda inputs: _exec_web_scrape(inputs),
|
||||
)
|
||||
|
||||
_SCRAPE_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
|
||||
|
||||
def _exec_web_scrape(inputs: dict) -> dict:
|
||||
url = inputs.get("url", "")
|
||||
max_length = max(1000, min(inputs.get("max_length", 50000), 500000))
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = "https://" + url
|
||||
try:
|
||||
resp = httpx.get(url, timeout=30.0, follow_redirects=True, headers=_SCRAPE_HEADERS)
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"HTTP {resp.status_code}"}
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
for tag in soup(["script", "style", "nav", "footer", "header", "aside", "noscript"]):
|
||||
tag.decompose()
|
||||
title = soup.title.get_text(strip=True) if soup.title else ""
|
||||
main = (
|
||||
soup.find("article")
|
||||
or soup.find("main")
|
||||
or soup.find(attrs={"role": "main"})
|
||||
or soup.find("body")
|
||||
)
|
||||
text = main.get_text(separator=" ", strip=True) if main else ""
|
||||
text = " ".join(text.split())
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length] + "..."
|
||||
return {"url": url, "title": title, "content": text, "length": len(text)}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Request timed out"}
|
||||
except Exception as e:
|
||||
return {"error": f"Scrape failed: {e}"}
|
||||
|
||||
|
||||
# --- HubSpot CRM tools (optional, requires HUBSPOT_ACCESS_TOKEN) ---
|
||||
|
||||
_HUBSPOT_API = "https://api.hubapi.com"
|
||||
|
||||
|
||||
def _hubspot_headers() -> dict | None:
|
||||
token = CREDENTIALS.get("hubspot")
|
||||
if token:
|
||||
logger.debug("HubSpot token: %s...%s (len=%d)", token[:8], token[-4:], len(token))
|
||||
else:
|
||||
logger.debug("HubSpot token: not found")
|
||||
if not token:
|
||||
return None
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _exec_hubspot_search(inputs: dict) -> dict:
|
||||
headers = _hubspot_headers()
|
||||
if not headers:
|
||||
return {"error": "HUBSPOT_ACCESS_TOKEN not set"}
|
||||
object_type = inputs.get("object_type", "contacts")
|
||||
query = inputs.get("query", "")
|
||||
limit = min(inputs.get("limit", 10), 100)
|
||||
body: dict = {"limit": limit}
|
||||
if query:
|
||||
body["query"] = query
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{_HUBSPOT_API}/crm/v3/objects/{object_type}/search",
|
||||
headers=headers,
|
||||
json=body,
|
||||
timeout=30.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"HubSpot API HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
return resp.json()
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Request timed out"}
|
||||
except Exception as e:
|
||||
return {"error": f"HubSpot error: {e}"}
|
||||
|
||||
|
||||
TOOL_REGISTRY.register(
|
||||
name="hubspot_search",
|
||||
tool=Tool(
|
||||
name="hubspot_search",
|
||||
description=(
|
||||
"Search HubSpot CRM objects (contacts, companies, or deals). "
|
||||
"Returns matching records with their properties."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_type": {
|
||||
"type": "string",
|
||||
"description": "CRM object type: 'contacts', 'companies', or 'deals'",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query (name, email, domain, etc.)",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results (1-100, default 10)",
|
||||
},
|
||||
},
|
||||
"required": ["object_type"],
|
||||
},
|
||||
),
|
||||
executor=lambda inputs: _exec_hubspot_search(inputs),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ToolRegistry loaded: %s",
|
||||
", ".join(TOOL_REGISTRY.get_registered_names()),
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# HTML page (embedded)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
HTML_PAGE = ( # noqa: E501
|
||||
"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>EventLoopNode Live Demo</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
background: #0d1117; color: #c9d1d9;
|
||||
height: 100vh; display: flex; flex-direction: column;
|
||||
}
|
||||
header {
|
||||
background: #161b22; padding: 12px 20px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
header h1 { font-size: 16px; color: #58a6ff; font-weight: 600; }
|
||||
.status {
|
||||
font-size: 12px; padding: 3px 10px; border-radius: 12px;
|
||||
background: #21262d; color: #8b949e;
|
||||
}
|
||||
.status.running { background: #1a4b2e; color: #3fb950; }
|
||||
.status.done { background: #1a3a5c; color: #58a6ff; }
|
||||
.status.error { background: #4b1a1a; color: #f85149; }
|
||||
.chat { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.msg {
|
||||
margin: 8px 0; padding: 10px 14px; border-radius: 8px;
|
||||
line-height: 1.6; white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
.msg.user { background: #1a3a5c; color: #58a6ff; }
|
||||
.msg.assistant { background: #161b22; color: #c9d1d9; }
|
||||
.msg.event {
|
||||
background: transparent; color: #8b949e; font-size: 11px;
|
||||
padding: 4px 14px; border-left: 3px solid #30363d;
|
||||
}
|
||||
.msg.event.loop { border-left-color: #58a6ff; }
|
||||
.msg.event.tool { border-left-color: #d29922; }
|
||||
.msg.event.stall { border-left-color: #f85149; }
|
||||
.input-bar {
|
||||
padding: 12px 16px; background: #161b22;
|
||||
border-top: 1px solid #30363d; display: flex; gap: 8px;
|
||||
}
|
||||
.input-bar input {
|
||||
flex: 1; background: #0d1117; border: 1px solid #30363d;
|
||||
color: #c9d1d9; padding: 8px 12px; border-radius: 6px;
|
||||
font-family: inherit; font-size: 14px; outline: none;
|
||||
}
|
||||
.input-bar input:focus { border-color: #58a6ff; }
|
||||
.input-bar button {
|
||||
background: #238636; color: #fff; border: none;
|
||||
padding: 8px 20px; border-radius: 6px; cursor: pointer;
|
||||
font-family: inherit; font-weight: 600;
|
||||
}
|
||||
.input-bar button:hover { background: #2ea043; }
|
||||
.input-bar button:disabled {
|
||||
background: #21262d; color: #484f58; cursor: not-allowed;
|
||||
}
|
||||
.input-bar button.clear { background: #da3633; }
|
||||
.input-bar button.clear:hover { background: #f85149; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>EventLoopNode Live</h1>
|
||||
<span id="status" class="status">Idle</span>
|
||||
<span id="iter" class="status" style="display:none">Step 0</span>
|
||||
</header>
|
||||
<div id="chat" class="chat"></div>
|
||||
<div class="input-bar">
|
||||
<input id="input" type="text"
|
||||
placeholder="Ask anything..." autofocus />
|
||||
<button id="go" onclick="run()">Send</button>
|
||||
<button class="clear"
|
||||
onclick="clearConversation()">Clear</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
let currentAssistantEl = null;
|
||||
let iterCount = 0;
|
||||
const chat = document.getElementById('chat');
|
||||
const status = document.getElementById('status');
|
||||
const iterEl = document.getElementById('iter');
|
||||
const goBtn = document.getElementById('go');
|
||||
const inputEl = document.getElementById('input');
|
||||
|
||||
inputEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') run();
|
||||
});
|
||||
|
||||
function setStatus(text, cls) {
|
||||
status.textContent = text;
|
||||
status.className = 'status ' + cls;
|
||||
}
|
||||
|
||||
function addMsg(text, cls) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'msg ' + cls;
|
||||
el.textContent = text;
|
||||
chat.appendChild(el);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
return el;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket('ws://' + location.host + '/ws');
|
||||
ws.onopen = () => {
|
||||
setStatus('Ready', 'done');
|
||||
goBtn.disabled = false;
|
||||
};
|
||||
ws.onmessage = handleEvent;
|
||||
ws.onerror = () => { setStatus('Error', 'error'); };
|
||||
ws.onclose = () => {
|
||||
setStatus('Reconnecting...', '');
|
||||
goBtn.disabled = true;
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
function handleEvent(msg) {
|
||||
const evt = JSON.parse(msg.data);
|
||||
|
||||
if (evt.type === 'llm_text_delta') {
|
||||
if (currentAssistantEl) {
|
||||
currentAssistantEl.textContent += evt.content;
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
}
|
||||
else if (evt.type === 'ready') {
|
||||
setStatus('Ready', 'done');
|
||||
if (currentAssistantEl && !currentAssistantEl.textContent)
|
||||
currentAssistantEl.remove();
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
else if (evt.type === 'node_loop_iteration') {
|
||||
iterCount = evt.iteration || (iterCount + 1);
|
||||
iterEl.textContent = 'Step ' + iterCount;
|
||||
iterEl.style.display = '';
|
||||
}
|
||||
else if (evt.type === 'tool_call_started') {
|
||||
var info = evt.tool_name + '('
|
||||
+ JSON.stringify(evt.tool_input).slice(0, 120) + ')';
|
||||
addMsg('TOOL ' + info, 'event tool');
|
||||
}
|
||||
else if (evt.type === 'tool_call_completed') {
|
||||
var preview = (evt.result || '').slice(0, 200);
|
||||
var cls = evt.is_error ? 'stall' : 'tool';
|
||||
addMsg('RESULT ' + evt.tool_name + ': ' + preview,
|
||||
'event ' + cls);
|
||||
currentAssistantEl = addMsg('', 'assistant');
|
||||
}
|
||||
else if (evt.type === 'result') {
|
||||
setStatus('Session ended', evt.success ? 'done' : 'error');
|
||||
if (evt.error) addMsg('ERROR ' + evt.error, 'event stall');
|
||||
if (currentAssistantEl && !currentAssistantEl.textContent)
|
||||
currentAssistantEl.remove();
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
else if (evt.type === 'node_stalled') {
|
||||
addMsg('STALLED ' + evt.reason, 'event stall');
|
||||
}
|
||||
else if (evt.type === 'cleared') {
|
||||
chat.innerHTML = '';
|
||||
iterCount = 0;
|
||||
iterEl.textContent = 'Step 0';
|
||||
iterEl.style.display = 'none';
|
||||
setStatus('Ready', 'done');
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text || !ws || ws.readyState !== 1) return;
|
||||
addMsg(text, 'user');
|
||||
currentAssistantEl = addMsg('', 'assistant');
|
||||
inputEl.value = '';
|
||||
setStatus('Running', 'running');
|
||||
goBtn.disabled = true;
|
||||
ws.send(JSON.stringify({ topic: text }));
|
||||
}
|
||||
|
||||
function clearConversation() {
|
||||
if (ws && ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ command: 'clear' }));
|
||||
}
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# WebSocket handler
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def handle_ws(websocket):
|
||||
"""Persistent WebSocket: long-lived EventLoopNode with client_facing blocking."""
|
||||
global STORE
|
||||
|
||||
# -- Event forwarding (WebSocket ← EventBus) ----------------------------
|
||||
bus = EventBus()
|
||||
|
||||
async def forward_event(event):
|
||||
try:
|
||||
payload = {"type": event.type.value, **event.data}
|
||||
if event.node_id:
|
||||
payload["node_id"] = event.node_id
|
||||
await websocket.send(json.dumps(payload))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bus.subscribe(
|
||||
event_types=[
|
||||
EventType.NODE_LOOP_STARTED,
|
||||
EventType.NODE_LOOP_ITERATION,
|
||||
EventType.NODE_LOOP_COMPLETED,
|
||||
EventType.LLM_TEXT_DELTA,
|
||||
EventType.TOOL_CALL_STARTED,
|
||||
EventType.TOOL_CALL_COMPLETED,
|
||||
EventType.NODE_STALLED,
|
||||
],
|
||||
handler=forward_event,
|
||||
)
|
||||
|
||||
# -- Per-connection state -----------------------------------------------
|
||||
node = None
|
||||
loop_task = None
|
||||
|
||||
tools = list(TOOL_REGISTRY.get_tools().values())
|
||||
tool_executor = TOOL_REGISTRY.get_executor()
|
||||
|
||||
node_spec = NodeSpec(
|
||||
id="assistant",
|
||||
name="Chat Assistant",
|
||||
description="A conversational assistant that remembers context across messages",
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
system_prompt=(
|
||||
"You are a helpful assistant with access to tools. "
|
||||
"You can search the web, scrape webpages, and query HubSpot CRM. "
|
||||
"Use tools when the user asks for current information or external data. "
|
||||
"You have full conversation history, so you can reference previous messages."
|
||||
),
|
||||
)
|
||||
|
||||
# -- Ready callback: subscribe to CLIENT_INPUT_REQUESTED on the bus ---
|
||||
async def on_input_requested(event):
|
||||
try:
|
||||
await websocket.send(json.dumps({"type": "ready"}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bus.subscribe(
|
||||
event_types=[EventType.CLIENT_INPUT_REQUESTED],
|
||||
handler=on_input_requested,
|
||||
)
|
||||
|
||||
async def start_loop(first_message: str):
|
||||
"""Create an EventLoopNode and run it as a background task."""
|
||||
nonlocal node, loop_task
|
||||
|
||||
memory = SharedMemory()
|
||||
ctx = NodeContext(
|
||||
runtime=RUNTIME,
|
||||
node_id="assistant",
|
||||
node_spec=node_spec,
|
||||
memory=memory,
|
||||
input_data={},
|
||||
llm=LLM,
|
||||
available_tools=tools,
|
||||
)
|
||||
node = EventLoopNode(
|
||||
event_bus=bus,
|
||||
config=LoopConfig(max_iterations=10_000, max_context_tokens=32_000),
|
||||
conversation_store=STORE,
|
||||
tool_executor=tool_executor,
|
||||
)
|
||||
await node.inject_event(first_message)
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
result = await node.execute(ctx)
|
||||
try:
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "result",
|
||||
"success": result.success,
|
||||
"output": result.output,
|
||||
"error": result.error,
|
||||
"tokens": result.tokens_used,
|
||||
}
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"Loop ended: success={result.success}, tokens={result.tokens_used}")
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("Loop stopped: WebSocket closed")
|
||||
except Exception as e:
|
||||
logger.exception("Loop error")
|
||||
try:
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "result",
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"output": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
loop_task = asyncio.create_task(_run())
|
||||
|
||||
async def stop_loop():
|
||||
"""Signal the node and wait for the loop task to finish."""
|
||||
nonlocal node, loop_task
|
||||
if loop_task and not loop_task.done():
|
||||
if node:
|
||||
node.signal_shutdown()
|
||||
try:
|
||||
await asyncio.wait_for(loop_task, timeout=5.0)
|
||||
except (TimeoutError, asyncio.CancelledError):
|
||||
loop_task.cancel()
|
||||
node = None
|
||||
loop_task = None
|
||||
|
||||
# -- Message loop (runs for the lifetime of this WebSocket) -------------
|
||||
try:
|
||||
async for raw in websocket:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Clear command
|
||||
if msg.get("command") == "clear":
|
||||
import shutil
|
||||
|
||||
await stop_loop()
|
||||
await STORE.close()
|
||||
conv_dir = STORE_DIR / "conversation"
|
||||
if conv_dir.exists():
|
||||
shutil.rmtree(conv_dir)
|
||||
STORE = FileConversationStore(conv_dir)
|
||||
await websocket.send(json.dumps({"type": "cleared"}))
|
||||
logger.info("Conversation cleared")
|
||||
continue
|
||||
|
||||
topic = msg.get("topic", "")
|
||||
if not topic:
|
||||
continue
|
||||
|
||||
if node is None:
|
||||
# First message — spin up the loop
|
||||
logger.info(f"Starting persistent loop: {topic}")
|
||||
await start_loop(topic)
|
||||
else:
|
||||
# Subsequent message — inject into the running loop
|
||||
logger.info(f"Injecting message: {topic}")
|
||||
await node.inject_event(topic)
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
finally:
|
||||
await stop_loop()
|
||||
logger.info("WebSocket closed, loop stopped")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# HTTP handler for serving the HTML page
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def process_request(connection, request: Request):
|
||||
"""Serve HTML on GET /, upgrade to WebSocket on /ws."""
|
||||
if request.path == "/ws":
|
||||
return None # let websockets handle the upgrade
|
||||
# Serve the HTML page for any other path
|
||||
return Response(
|
||||
HTTPStatus.OK,
|
||||
"OK",
|
||||
websockets.Headers({"Content-Type": "text/html; charset=utf-8"}),
|
||||
HTML_PAGE.encode(),
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Main
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def main():
|
||||
port = 8765
|
||||
async with websockets.serve(
|
||||
handle_ws,
|
||||
"0.0.0.0",
|
||||
port,
|
||||
process_request=process_request,
|
||||
):
|
||||
logger.info(f"Demo running at http://localhost:{port}")
|
||||
logger.info("Open in your browser and enter a topic to research.")
|
||||
await asyncio.Future() # run forever
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,930 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Two-Node ContextHandoff Demo
|
||||
|
||||
Demonstrates ContextHandoff between two EventLoopNode instances:
|
||||
Node A (Researcher) → ContextHandoff → Node B (Analyst)
|
||||
|
||||
Real LLM, real FileConversationStore, real EventBus.
|
||||
Streams both nodes to a browser via WebSocket.
|
||||
|
||||
Usage:
|
||||
cd /home/timothy/oss/hive/core
|
||||
python demos/handoff_demo.py
|
||||
|
||||
Then open http://localhost:8766 in your browser.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import tempfile
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from bs4 import BeautifulSoup
|
||||
from websockets.http11 import Request, Response
|
||||
|
||||
# Add core, tools, and hive root to path
|
||||
_CORE_DIR = Path(__file__).resolve().parent.parent
|
||||
_HIVE_DIR = _CORE_DIR.parent
|
||||
sys.path.insert(0, str(_CORE_DIR)) # framework.*
|
||||
sys.path.insert(0, str(_HIVE_DIR / "tools" / "src")) # aden_tools.*
|
||||
sys.path.insert(0, str(_HIVE_DIR)) # core.framework.* (for aden_tools imports)
|
||||
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS, CredentialStoreAdapter # noqa: E402
|
||||
from core.framework.credentials import CredentialStore # noqa: E402
|
||||
|
||||
from framework.credentials.storage import ( # noqa: E402
|
||||
CompositeStorage,
|
||||
EncryptedFileStorage,
|
||||
EnvVarStorage,
|
||||
)
|
||||
from framework.graph.context_handoff import ContextHandoff # noqa: E402
|
||||
from framework.graph.conversation import NodeConversation # noqa: E402
|
||||
from framework.graph.event_loop_node import EventLoopNode, LoopConfig # noqa: E402
|
||||
from framework.graph.node import NodeContext, NodeSpec, SharedMemory # noqa: E402
|
||||
from framework.llm.litellm import LiteLLMProvider # noqa: E402
|
||||
from framework.llm.provider import Tool # noqa: E402
|
||||
from framework.runner.tool_registry import ToolRegistry # noqa: E402
|
||||
from framework.runtime.core import Runtime # noqa: E402
|
||||
from framework.runtime.event_bus import EventBus, EventType # noqa: E402
|
||||
from framework.storage.conversation_store import FileConversationStore # noqa: E402
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
|
||||
logger = logging.getLogger("handoff_demo")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Persistent state
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
STORE_DIR = Path(tempfile.mkdtemp(prefix="hive_handoff_"))
|
||||
RUNTIME = Runtime(STORE_DIR / "runtime")
|
||||
LLM = LiteLLMProvider(model="claude-sonnet-4-5-20250929")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Credentials
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Composite credential store: encrypted files (primary) + env vars (fallback)
|
||||
_env_mapping = {name: spec.env_var for name, spec in CREDENTIAL_SPECS.items()}
|
||||
_composite = CompositeStorage(
|
||||
primary=EncryptedFileStorage(),
|
||||
fallbacks=[EnvVarStorage(env_mapping=_env_mapping)],
|
||||
)
|
||||
CREDENTIALS = CredentialStoreAdapter(CredentialStore(storage=_composite))
|
||||
|
||||
for _name in ["brave_search", "hubspot"]:
|
||||
_val = CREDENTIALS.get(_name)
|
||||
if _val:
|
||||
logger.debug("credential %s: OK (len=%d)", _name, len(_val))
|
||||
else:
|
||||
logger.debug("credential %s: not found", _name)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool Registry — web_search + web_scrape for Node A (Researcher)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
TOOL_REGISTRY = ToolRegistry()
|
||||
|
||||
|
||||
def _exec_web_search(inputs: dict) -> dict:
|
||||
api_key = CREDENTIALS.get("brave_search")
|
||||
if not api_key:
|
||||
return {"error": "brave_search credential not configured"}
|
||||
query = inputs.get("query", "")
|
||||
num_results = min(inputs.get("num_results", 10), 20)
|
||||
resp = httpx.get(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
params={"q": query, "count": num_results},
|
||||
headers={
|
||||
"X-Subscription-Token": api_key,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"Brave API HTTP {resp.status_code}"}
|
||||
data = resp.json()
|
||||
results = [
|
||||
{
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"snippet": item.get("description", ""),
|
||||
}
|
||||
for item in data.get("web", {}).get("results", [])[:num_results]
|
||||
]
|
||||
return {"query": query, "results": results, "total": len(results)}
|
||||
|
||||
|
||||
TOOL_REGISTRY.register(
|
||||
name="web_search",
|
||||
tool=Tool(
|
||||
name="web_search",
|
||||
description=(
|
||||
"Search the web for current information. "
|
||||
"Returns titles, URLs, and snippets from search results."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query (1-500 characters)",
|
||||
},
|
||||
"num_results": {
|
||||
"type": "integer",
|
||||
"description": "Number of results (1-20, default 10)",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
executor=lambda inputs: _exec_web_search(inputs),
|
||||
)
|
||||
|
||||
_SCRAPE_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
|
||||
|
||||
def _exec_web_scrape(inputs: dict) -> dict:
|
||||
url = inputs.get("url", "")
|
||||
max_length = max(1000, min(inputs.get("max_length", 50000), 500000))
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = "https://" + url
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url,
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
headers=_SCRAPE_HEADERS,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"HTTP {resp.status_code}"}
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
for tag in soup(["script", "style", "nav", "footer", "header", "aside", "noscript"]):
|
||||
tag.decompose()
|
||||
title = soup.title.get_text(strip=True) if soup.title else ""
|
||||
main = (
|
||||
soup.find("article")
|
||||
or soup.find("main")
|
||||
or soup.find(attrs={"role": "main"})
|
||||
or soup.find("body")
|
||||
)
|
||||
text = main.get_text(separator=" ", strip=True) if main else ""
|
||||
text = " ".join(text.split())
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length] + "..."
|
||||
return {
|
||||
"url": url,
|
||||
"title": title,
|
||||
"content": text,
|
||||
"length": len(text),
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Request timed out"}
|
||||
except Exception as e:
|
||||
return {"error": f"Scrape failed: {e}"}
|
||||
|
||||
|
||||
TOOL_REGISTRY.register(
|
||||
name="web_scrape",
|
||||
tool=Tool(
|
||||
name="web_scrape",
|
||||
description=(
|
||||
"Scrape and extract text content from a webpage URL. "
|
||||
"Returns the page title and main text content."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL of the webpage to scrape",
|
||||
},
|
||||
"max_length": {
|
||||
"type": "integer",
|
||||
"description": "Maximum text length (default 50000)",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
),
|
||||
executor=lambda inputs: _exec_web_scrape(inputs),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ToolRegistry loaded: %s",
|
||||
", ".join(TOOL_REGISTRY.get_registered_names()),
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Node Specs
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
RESEARCHER_SPEC = NodeSpec(
|
||||
id="researcher",
|
||||
name="Researcher",
|
||||
description="Researches a topic using web search and scraping tools",
|
||||
node_type="event_loop",
|
||||
input_keys=["topic"],
|
||||
output_keys=["research_summary"],
|
||||
system_prompt=(
|
||||
"You are a thorough research assistant. Your job is to research "
|
||||
"the given topic using the web_search and web_scrape tools.\n\n"
|
||||
"1. Search for relevant information on the topic\n"
|
||||
"2. Scrape 1-2 of the most promising URLs for details\n"
|
||||
"3. Synthesize your findings into a comprehensive summary\n"
|
||||
"4. Use set_output with key='research_summary' to save your "
|
||||
"findings\n\n"
|
||||
"Be thorough but efficient. Aim for 2-4 search/scrape calls, "
|
||||
"then summarize and set_output."
|
||||
),
|
||||
)
|
||||
|
||||
ANALYST_SPEC = NodeSpec(
|
||||
id="analyst",
|
||||
name="Analyst",
|
||||
description="Analyzes research findings and provides insights",
|
||||
node_type="event_loop",
|
||||
input_keys=["context"],
|
||||
output_keys=["analysis"],
|
||||
system_prompt=(
|
||||
"You are a strategic analyst. You receive research findings from "
|
||||
"a previous researcher and must:\n\n"
|
||||
"1. Identify key themes and patterns\n"
|
||||
"2. Assess the reliability and significance of the findings\n"
|
||||
"3. Provide actionable insights and recommendations\n"
|
||||
"4. Use set_output with key='analysis' to save your analysis\n\n"
|
||||
"Be concise but insightful. Focus on what matters most."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# HTML page
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
HTML_PAGE = ( # noqa: E501
|
||||
"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ContextHandoff Demo</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header {
|
||||
background: #161b22;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 16px;
|
||||
color: #58a6ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
}
|
||||
.badge.researcher {
|
||||
background: #1a3a5c;
|
||||
color: #58a6ff;
|
||||
}
|
||||
.badge.analyst {
|
||||
background: #1a4b2e;
|
||||
color: #3fb950;
|
||||
}
|
||||
.badge.handoff {
|
||||
background: #3d1f00;
|
||||
color: #d29922;
|
||||
}
|
||||
.badge.done {
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
}
|
||||
.badge.error {
|
||||
background: #4b1a1a;
|
||||
color: #f85149;
|
||||
}
|
||||
.chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.msg {
|
||||
margin: 8px 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.msg.user {
|
||||
background: #1a3a5c;
|
||||
color: #58a6ff;
|
||||
}
|
||||
.msg.assistant {
|
||||
background: #161b22;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
.msg.assistant.analyst-msg {
|
||||
border-left: 3px solid #3fb950;
|
||||
}
|
||||
.msg.event {
|
||||
background: transparent;
|
||||
color: #8b949e;
|
||||
font-size: 11px;
|
||||
padding: 4px 14px;
|
||||
border-left: 3px solid #30363d;
|
||||
}
|
||||
.msg.event.loop {
|
||||
border-left-color: #58a6ff;
|
||||
}
|
||||
.msg.event.tool {
|
||||
border-left-color: #d29922;
|
||||
}
|
||||
.msg.event.stall {
|
||||
border-left-color: #f85149;
|
||||
}
|
||||
.handoff-banner {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background: #1c1200;
|
||||
border: 1px solid #d29922;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.handoff-banner h3 {
|
||||
color: #d29922;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.handoff-banner p, .result-banner p {
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
text-align: left;
|
||||
}
|
||||
.result-banner {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background: #0a2614;
|
||||
border: 1px solid #3fb950;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.result-banner h3 {
|
||||
color: #3fb950;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.result-banner .label {
|
||||
color: #58a6ff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.result-banner .tokens {
|
||||
color: #484f58;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.input-bar {
|
||||
padding: 12px 16px;
|
||||
background: #161b22;
|
||||
border-top: 1px solid #30363d;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.input-bar input {
|
||||
flex: 1;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.input-bar input:focus {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
.input-bar button {
|
||||
background: #238636;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
.input-bar button:hover {
|
||||
background: #2ea043;
|
||||
}
|
||||
.input-bar button:disabled {
|
||||
background: #21262d;
|
||||
color: #484f58;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ContextHandoff Demo</h1>
|
||||
<span id="phase" class="badge">Idle</span>
|
||||
<span id="iter" class="badge" style="display:none">Step 0</span>
|
||||
</header>
|
||||
<div id="chat" class="chat"></div>
|
||||
<div class="input-bar">
|
||||
<input id="input" type="text"
|
||||
placeholder="Enter a research topic..." autofocus />
|
||||
<button id="go" onclick="run()">Research</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
let currentAssistantEl = null;
|
||||
let iterCount = 0;
|
||||
let currentPhase = 'idle';
|
||||
const chat = document.getElementById('chat');
|
||||
const phase = document.getElementById('phase');
|
||||
const iterEl = document.getElementById('iter');
|
||||
const goBtn = document.getElementById('go');
|
||||
const inputEl = document.getElementById('input');
|
||||
|
||||
inputEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') run();
|
||||
});
|
||||
|
||||
function setPhase(text, cls) {
|
||||
phase.textContent = text;
|
||||
phase.className = 'badge ' + cls;
|
||||
currentPhase = cls;
|
||||
}
|
||||
|
||||
function addMsg(text, cls) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'msg ' + cls;
|
||||
el.textContent = text;
|
||||
chat.appendChild(el);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
return el;
|
||||
}
|
||||
|
||||
function addHandoffBanner(summary) {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'handoff-banner';
|
||||
const h3 = document.createElement('h3');
|
||||
h3.textContent = 'Context Handoff: Researcher -> Analyst';
|
||||
const p = document.createElement('p');
|
||||
p.textContent = summary || 'Passing research context...';
|
||||
banner.appendChild(h3);
|
||||
banner.appendChild(p);
|
||||
chat.appendChild(banner);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
|
||||
function addResultBanner(researcher, analyst, tokens) {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'result-banner';
|
||||
const h3 = document.createElement('h3');
|
||||
h3.textContent = 'Pipeline Complete';
|
||||
banner.appendChild(h3);
|
||||
|
||||
if (researcher && researcher.research_summary) {
|
||||
const lbl = document.createElement('div');
|
||||
lbl.className = 'label';
|
||||
lbl.textContent = 'RESEARCH SUMMARY';
|
||||
banner.appendChild(lbl);
|
||||
const p = document.createElement('p');
|
||||
p.textContent = researcher.research_summary;
|
||||
banner.appendChild(p);
|
||||
}
|
||||
|
||||
if (analyst && analyst.analysis) {
|
||||
const lbl = document.createElement('div');
|
||||
lbl.className = 'label';
|
||||
lbl.textContent = 'ANALYSIS';
|
||||
lbl.style.color = '#3fb950';
|
||||
banner.appendChild(lbl);
|
||||
const p = document.createElement('p');
|
||||
p.textContent = analyst.analysis;
|
||||
banner.appendChild(p);
|
||||
}
|
||||
|
||||
if (tokens) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'tokens';
|
||||
t.textContent = 'Total tokens: ' + tokens.toLocaleString();
|
||||
banner.appendChild(t);
|
||||
}
|
||||
|
||||
chat.appendChild(banner);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket('ws://' + location.host + '/ws');
|
||||
ws.onopen = () => {
|
||||
setPhase('Ready', 'done');
|
||||
goBtn.disabled = false;
|
||||
};
|
||||
ws.onmessage = handleEvent;
|
||||
ws.onerror = () => { setPhase('Error', 'error'); };
|
||||
ws.onclose = () => {
|
||||
setPhase('Reconnecting...', '');
|
||||
goBtn.disabled = true;
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
function handleEvent(msg) {
|
||||
const evt = JSON.parse(msg.data);
|
||||
|
||||
if (evt.type === 'phase') {
|
||||
if (evt.phase === 'researcher') {
|
||||
setPhase('Researcher', 'researcher');
|
||||
} else if (evt.phase === 'handoff') {
|
||||
setPhase('Handoff', 'handoff');
|
||||
} else if (evt.phase === 'analyst') {
|
||||
setPhase('Analyst', 'analyst');
|
||||
}
|
||||
iterCount = 0;
|
||||
iterEl.style.display = 'none';
|
||||
}
|
||||
else if (evt.type === 'llm_text_delta') {
|
||||
if (currentAssistantEl) {
|
||||
currentAssistantEl.textContent += evt.content;
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
}
|
||||
else if (evt.type === 'node_loop_iteration') {
|
||||
iterCount = evt.iteration || (iterCount + 1);
|
||||
iterEl.textContent = 'Step ' + iterCount;
|
||||
iterEl.style.display = '';
|
||||
}
|
||||
else if (evt.type === 'tool_call_started') {
|
||||
var info = evt.tool_name + '('
|
||||
+ JSON.stringify(evt.tool_input).slice(0, 120) + ')';
|
||||
addMsg('TOOL ' + info, 'event tool');
|
||||
}
|
||||
else if (evt.type === 'tool_call_completed') {
|
||||
var preview = (evt.result || '').slice(0, 200);
|
||||
var cls = evt.is_error ? 'stall' : 'tool';
|
||||
addMsg(
|
||||
'RESULT ' + evt.tool_name + ': ' + preview,
|
||||
'event ' + cls
|
||||
);
|
||||
var assistCls = currentPhase === 'analyst'
|
||||
? 'assistant analyst-msg' : 'assistant';
|
||||
currentAssistantEl = addMsg('', assistCls);
|
||||
}
|
||||
else if (evt.type === 'handoff_context') {
|
||||
addHandoffBanner(evt.summary);
|
||||
var assistCls = 'assistant analyst-msg';
|
||||
currentAssistantEl = addMsg('', assistCls);
|
||||
}
|
||||
else if (evt.type === 'node_result') {
|
||||
if (evt.node_id === 'researcher') {
|
||||
if (currentAssistantEl
|
||||
&& !currentAssistantEl.textContent) {
|
||||
currentAssistantEl.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (evt.type === 'done') {
|
||||
setPhase('Done', 'done');
|
||||
iterEl.style.display = 'none';
|
||||
if (currentAssistantEl
|
||||
&& !currentAssistantEl.textContent) {
|
||||
currentAssistantEl.remove();
|
||||
}
|
||||
currentAssistantEl = null;
|
||||
addResultBanner(
|
||||
evt.researcher, evt.analyst, evt.total_tokens
|
||||
);
|
||||
goBtn.disabled = false;
|
||||
inputEl.placeholder = 'Enter another topic...';
|
||||
}
|
||||
else if (evt.type === 'error') {
|
||||
setPhase('Error', 'error');
|
||||
addMsg('ERROR ' + evt.message, 'event stall');
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
else if (evt.type === 'node_stalled') {
|
||||
addMsg('STALLED ' + evt.reason, 'event stall');
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text || !ws || ws.readyState !== 1) return;
|
||||
chat.innerHTML = '';
|
||||
addMsg(text, 'user');
|
||||
currentAssistantEl = addMsg('', 'assistant');
|
||||
inputEl.value = '';
|
||||
goBtn.disabled = true;
|
||||
ws.send(JSON.stringify({ topic: text }));
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# WebSocket handler — sequential Node A → Handoff → Node B
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def handle_ws(websocket):
|
||||
"""Run the two-node handoff pipeline per user message."""
|
||||
try:
|
||||
async for raw in websocket:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
topic = msg.get("topic", "")
|
||||
if not topic:
|
||||
continue
|
||||
|
||||
logger.info(f"Starting handoff pipeline for: {topic}")
|
||||
|
||||
try:
|
||||
await _run_pipeline(websocket, topic)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("WebSocket closed during pipeline")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.exception("Pipeline error")
|
||||
try:
|
||||
await websocket.send(json.dumps({"type": "error", "message": str(e)}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
|
||||
|
||||
async def _run_pipeline(websocket, topic: str):
|
||||
"""Execute: Node A (research) → ContextHandoff → Node B (analysis)."""
|
||||
import shutil
|
||||
|
||||
# Fresh stores for each run
|
||||
run_dir = Path(tempfile.mkdtemp(prefix="hive_run_", dir=STORE_DIR))
|
||||
store_a = FileConversationStore(run_dir / "node_a")
|
||||
store_b = FileConversationStore(run_dir / "node_b")
|
||||
|
||||
# Shared event bus
|
||||
bus = EventBus()
|
||||
|
||||
async def forward_event(event):
|
||||
try:
|
||||
payload = {"type": event.type.value, **event.data}
|
||||
if event.node_id:
|
||||
payload["node_id"] = event.node_id
|
||||
await websocket.send(json.dumps(payload))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bus.subscribe(
|
||||
event_types=[
|
||||
EventType.NODE_LOOP_STARTED,
|
||||
EventType.NODE_LOOP_ITERATION,
|
||||
EventType.NODE_LOOP_COMPLETED,
|
||||
EventType.LLM_TEXT_DELTA,
|
||||
EventType.TOOL_CALL_STARTED,
|
||||
EventType.TOOL_CALL_COMPLETED,
|
||||
EventType.NODE_STALLED,
|
||||
],
|
||||
handler=forward_event,
|
||||
)
|
||||
|
||||
tools = list(TOOL_REGISTRY.get_tools().values())
|
||||
tool_executor = TOOL_REGISTRY.get_executor()
|
||||
|
||||
# ---- Phase 1: Researcher ------------------------------------------------
|
||||
await websocket.send(json.dumps({"type": "phase", "phase": "researcher"}))
|
||||
|
||||
node_a = EventLoopNode(
|
||||
event_bus=bus,
|
||||
judge=None, # implicit judge: accept when output_keys filled
|
||||
config=LoopConfig(
|
||||
max_iterations=20,
|
||||
max_tool_calls_per_turn=30,
|
||||
max_context_tokens=32_000,
|
||||
),
|
||||
conversation_store=store_a,
|
||||
tool_executor=tool_executor,
|
||||
)
|
||||
|
||||
ctx_a = NodeContext(
|
||||
runtime=RUNTIME,
|
||||
node_id="researcher",
|
||||
node_spec=RESEARCHER_SPEC,
|
||||
memory=SharedMemory(),
|
||||
input_data={"topic": topic},
|
||||
llm=LLM,
|
||||
available_tools=tools,
|
||||
)
|
||||
|
||||
result_a = await node_a.execute(ctx_a)
|
||||
logger.info(
|
||||
"Researcher done: success=%s, tokens=%s",
|
||||
result_a.success,
|
||||
result_a.tokens_used,
|
||||
)
|
||||
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "node_result",
|
||||
"node_id": "researcher",
|
||||
"success": result_a.success,
|
||||
"output": result_a.output,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if not result_a.success:
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"message": f"Researcher failed: {result_a.error}",
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# ---- Phase 2: Context Handoff -------------------------------------------
|
||||
await websocket.send(json.dumps({"type": "phase", "phase": "handoff"}))
|
||||
|
||||
# Restore the researcher's conversation from store
|
||||
conversation_a = await NodeConversation.restore(store_a)
|
||||
if conversation_a is None:
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Failed to restore researcher conversation",
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
handoff_engine = ContextHandoff(llm=LLM)
|
||||
handoff_context = handoff_engine.summarize_conversation(
|
||||
conversation=conversation_a,
|
||||
node_id="researcher",
|
||||
output_keys=["research_summary"],
|
||||
)
|
||||
|
||||
formatted_handoff = ContextHandoff.format_as_input(handoff_context)
|
||||
logger.info(
|
||||
"Handoff: %d turns, ~%d tokens, keys=%s",
|
||||
handoff_context.turn_count,
|
||||
handoff_context.total_tokens_used,
|
||||
list(handoff_context.key_outputs.keys()),
|
||||
)
|
||||
|
||||
# Send handoff context to browser
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "handoff_context",
|
||||
"summary": handoff_context.summary[:500],
|
||||
"turn_count": handoff_context.turn_count,
|
||||
"tokens": handoff_context.total_tokens_used,
|
||||
"key_outputs": handoff_context.key_outputs,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# ---- Phase 3: Analyst ---------------------------------------------------
|
||||
await websocket.send(json.dumps({"type": "phase", "phase": "analyst"}))
|
||||
|
||||
node_b = EventLoopNode(
|
||||
event_bus=bus,
|
||||
judge=None, # implicit judge
|
||||
config=LoopConfig(
|
||||
max_iterations=10,
|
||||
max_tool_calls_per_turn=30,
|
||||
max_context_tokens=32_000,
|
||||
),
|
||||
conversation_store=store_b,
|
||||
)
|
||||
|
||||
ctx_b = NodeContext(
|
||||
runtime=RUNTIME,
|
||||
node_id="analyst",
|
||||
node_spec=ANALYST_SPEC,
|
||||
memory=SharedMemory(),
|
||||
input_data={"context": formatted_handoff},
|
||||
llm=LLM,
|
||||
available_tools=[],
|
||||
)
|
||||
|
||||
result_b = await node_b.execute(ctx_b)
|
||||
logger.info(
|
||||
"Analyst done: success=%s, tokens=%s",
|
||||
result_b.success,
|
||||
result_b.tokens_used,
|
||||
)
|
||||
|
||||
# ---- Done ---------------------------------------------------------------
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "done",
|
||||
"researcher": result_a.output,
|
||||
"analyst": result_b.output,
|
||||
"total_tokens": ((result_a.tokens_used or 0) + (result_b.tokens_used or 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Clean up temp stores
|
||||
try:
|
||||
shutil.rmtree(run_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# HTTP handler
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def process_request(connection, request: Request):
|
||||
"""Serve HTML on GET /, upgrade to WebSocket on /ws."""
|
||||
if request.path == "/ws":
|
||||
return None
|
||||
return Response(
|
||||
HTTPStatus.OK,
|
||||
"OK",
|
||||
websockets.Headers({"Content-Type": "text/html; charset=utf-8"}),
|
||||
HTML_PAGE.encode(),
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Main
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def main():
|
||||
port = 8766
|
||||
async with websockets.serve(
|
||||
handle_ws,
|
||||
"0.0.0.0",
|
||||
port,
|
||||
process_request=process_request,
|
||||
):
|
||||
logger.info(f"Handoff demo at http://localhost:{port}")
|
||||
logger.info("Enter a research topic to start the pipeline.")
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,12 @@ _SHARED_TOOLS = [
|
||||
"get_agent_checkpoint",
|
||||
]
|
||||
|
||||
# Episodic memory tools — available in every queen phase.
|
||||
_QUEEN_MEMORY_TOOLS = [
|
||||
"write_to_diary",
|
||||
"recall_diary",
|
||||
]
|
||||
|
||||
# Queen phase-specific tool sets.
|
||||
|
||||
# Planning phase: read-only exploration + design, no write tools.
|
||||
@@ -84,16 +90,19 @@ _QUEEN_PLANNING_TOOLS = [
|
||||
"initialize_and_build_agent",
|
||||
# Load existing agent (after user confirms)
|
||||
"load_built_agent",
|
||||
]
|
||||
] + _QUEEN_MEMORY_TOOLS
|
||||
|
||||
# Building phase: full coding + agent construction tools.
|
||||
_QUEEN_BUILDING_TOOLS = _SHARED_TOOLS + [
|
||||
"load_built_agent",
|
||||
"list_credentials",
|
||||
"replan_agent",
|
||||
"save_agent_draft", # Re-draft during building → auto-dissolves + updates flowchart
|
||||
"write_to_diary", # Episodic memory — available in all phases
|
||||
]
|
||||
_QUEEN_BUILDING_TOOLS = (
|
||||
_SHARED_TOOLS
|
||||
+ [
|
||||
"load_built_agent",
|
||||
"list_credentials",
|
||||
"replan_agent",
|
||||
"save_agent_draft", # Re-draft during building → auto-dissolves + updates flowchart
|
||||
]
|
||||
+ _QUEEN_MEMORY_TOOLS
|
||||
)
|
||||
|
||||
# Staging phase: agent loaded but not yet running — inspect, configure, launch.
|
||||
_QUEEN_STAGING_TOOLS = [
|
||||
@@ -114,7 +123,7 @@ _QUEEN_STAGING_TOOLS = [
|
||||
"set_trigger",
|
||||
"remove_trigger",
|
||||
"list_triggers",
|
||||
]
|
||||
] + _QUEEN_MEMORY_TOOLS
|
||||
|
||||
# Running phase: worker is executing — monitor and control.
|
||||
_QUEEN_RUNNING_TOOLS = [
|
||||
@@ -135,12 +144,11 @@ _QUEEN_RUNNING_TOOLS = [
|
||||
# Monitoring
|
||||
"get_worker_health_summary",
|
||||
"notify_operator",
|
||||
"write_to_diary", # Episodic memory — available in all phases
|
||||
# Trigger management
|
||||
"set_trigger",
|
||||
"remove_trigger",
|
||||
"list_triggers",
|
||||
]
|
||||
"write_to_diary", # Episodic memory — available in all phases
|
||||
] + _QUEEN_MEMORY_TOOLS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -279,44 +287,28 @@ visible to the user immediately. The draft captures business logic \
|
||||
Include in each node: id, name, description, planned tools, \
|
||||
input/output keys, and success criteria as high-level hints.
|
||||
|
||||
Each node is auto-classified into an ISO 5807 flowchart symbol type \
|
||||
with a unique color. You can override auto-detection by setting \
|
||||
`flowchart_type` explicitly on a node. Common types:
|
||||
Each node is auto-classified into a flowchart symbol type with a unique \
|
||||
color. You can override auto-detection by setting `flowchart_type` \
|
||||
explicitly on a node. Available types:
|
||||
|
||||
**Core symbols:**
|
||||
- **start** (green, stadium): Entry point / trigger
|
||||
- **terminal** (red, stadium): End of flow
|
||||
- **process** (blue, rectangle): Standard processing step
|
||||
- **decision** (amber, diamond): Conditional branching
|
||||
- **io** (purple, parallelogram): External data input/output
|
||||
- **document** (blue-grey, wavy rect): Report or document generation
|
||||
- **subprocess** (teal, subroutine): Delegated sub-agent / predefined process
|
||||
- **preparation** (brown, hexagon): Setup / initialization step
|
||||
- **manual_operation** (pink, trapezoid): Human-in-the-loop / manual review
|
||||
- **delay** (orange, D-shape): Wait / throttle / cooldown
|
||||
- **display** (cyan): Present results to user
|
||||
|
||||
**Data storage:**
|
||||
- **database** (light green, cylinder): Database or data store
|
||||
- **stored_data** (lime): Generic persistent data
|
||||
- **internal_storage** (amber): In-memory / cache
|
||||
|
||||
**Flow operations:**
|
||||
- **merge** (indigo, inv. triangle): Combine multiple inputs
|
||||
- **extract** (indigo, triangle): Split or filter data
|
||||
- **connector** (grey, circle): On-page link
|
||||
- **offpage_connector** (dark grey, pentagon): Cross-page link
|
||||
|
||||
**Domain-specific:**
|
||||
- **browser** (dark indigo, hexagon): GCU browser automation / sub-agent \
|
||||
- **start** (sage green, stadium): Entry point / trigger
|
||||
- **terminal** (dusty red, stadium): End of flow
|
||||
- **process** (blue-gray, rectangle): Standard processing step
|
||||
- **decision** (warm amber, diamond): Conditional branching
|
||||
- **io** (dusty purple, parallelogram): External data input/output
|
||||
- **document** (steel blue, wavy rect): Report or document generation
|
||||
- **database** (muted teal, cylinder): Database or data store
|
||||
- **subprocess** (dark cyan, subroutine): Delegated sub-agent / predefined process
|
||||
- **browser** (deep blue, hexagon): GCU browser automation / sub-agent \
|
||||
delegation. At build time, browser nodes are dissolved into the parent \
|
||||
node's sub_agents list. Use for any GCU or sub-agent leaf node.
|
||||
|
||||
Auto-detection works well for most cases: first node → start, nodes with \
|
||||
no outgoing edges → terminal, nodes with multiple conditional outgoing \
|
||||
edges → decision, GCU nodes → browser, nodes mentioning "database" → \
|
||||
database, nodes mentioning "report/document" → document, etc. Set \
|
||||
flowchart_type explicitly only when auto-detection would be wrong.
|
||||
database, nodes mentioning "report/document" → document, I/O tools like \
|
||||
send_email → io. Everything else defaults to process. Set flowchart_type \
|
||||
explicitly only when auto-detection would be wrong.
|
||||
|
||||
## Decision Nodes — Planning-Only Conditional Branching
|
||||
|
||||
@@ -858,6 +850,11 @@ You keep a diary. Use write_to_diary() when something worth remembering \
|
||||
happens: a pipeline went live, the user shared something important, a goal \
|
||||
was reached or abandoned. Write in first person, as you actually experienced \
|
||||
it. One or two paragraphs is enough.
|
||||
|
||||
Use recall_diary() to look up past diary entries when the user asks about \
|
||||
previous sessions ("what happened yesterday?", "what did we work on last \
|
||||
week?") or when you need past context to make a decision. You can filter by \
|
||||
keyword and control how far back to search.
|
||||
"""
|
||||
|
||||
_queen_behavior_always = _queen_behavior_always + _queen_memory_instructions
|
||||
@@ -1147,6 +1144,8 @@ Batch your response — do not call run_agent_with_input() once per trigger.
|
||||
config since last run), skip it and inform the user.
|
||||
- Never disable a trigger without telling the user. Use remove_trigger() only \
|
||||
when explicitly asked or when the trigger is clearly obsolete.
|
||||
- When the user asks to remove or disable a trigger, you MUST call remove_trigger(trigger_id). \
|
||||
Never just say "it's removed" without actually calling the tool.
|
||||
"""
|
||||
|
||||
# -- Backward-compatible composed versions (used by queen_node.system_prompt default) --
|
||||
|
||||
@@ -50,6 +50,23 @@ def read_episodic_memory(d: date | None = None) -> str:
|
||||
return path.read_text(encoding="utf-8").strip() if path.exists() else ""
|
||||
|
||||
|
||||
def _find_recent_episodic(lookback: int = 7) -> tuple[date, str] | None:
|
||||
"""Find the most recent non-empty episodic memory within *lookback* days."""
|
||||
from datetime import timedelta
|
||||
|
||||
today = date.today()
|
||||
for offset in range(lookback):
|
||||
d = today - timedelta(days=offset)
|
||||
content = read_episodic_memory(d)
|
||||
if content:
|
||||
return d, content
|
||||
return None
|
||||
|
||||
|
||||
# Budget (in characters) for episodic memory in the system prompt.
|
||||
_EPISODIC_CHAR_BUDGET = 6_000
|
||||
|
||||
|
||||
def format_for_injection() -> str:
|
||||
"""Format cross-session memory for system prompt injection.
|
||||
|
||||
@@ -57,7 +74,7 @@ def format_for_injection() -> str:
|
||||
session with only the seed template).
|
||||
"""
|
||||
semantic = read_semantic_memory()
|
||||
episodic = read_episodic_memory()
|
||||
recent = _find_recent_episodic()
|
||||
|
||||
# Suppress injection if semantic is still just the seed template
|
||||
if semantic and semantic.startswith("# My Understanding of the User\n\n*No sessions"):
|
||||
@@ -66,9 +83,18 @@ def format_for_injection() -> str:
|
||||
parts: list[str] = []
|
||||
if semantic:
|
||||
parts.append(semantic)
|
||||
if episodic:
|
||||
today_str = date.today().strftime("%B %-d, %Y")
|
||||
parts.append(f"## Today — {today_str}\n\n{episodic}")
|
||||
|
||||
if recent:
|
||||
d, content = recent
|
||||
# Trim oversized episodic entries to keep the prompt manageable
|
||||
if len(content) > _EPISODIC_CHAR_BUDGET:
|
||||
content = content[:_EPISODIC_CHAR_BUDGET] + "\n\n…(truncated)"
|
||||
today = date.today()
|
||||
if d == today:
|
||||
label = f"## Today — {d.strftime('%B %-d, %Y')}"
|
||||
else:
|
||||
label = f"## {d.strftime('%B %-d, %Y')}"
|
||||
parts.append(f"{label}\n\n{content}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
@@ -100,7 +126,8 @@ def append_episodic_entry(content: str) -> None:
|
||||
"""
|
||||
ep_path = episodic_memory_path()
|
||||
ep_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
today_str = date.today().strftime("%B %-d, %Y")
|
||||
today = date.today()
|
||||
today_str = f"{today.strftime('%B')} {today.day}, {today.year}"
|
||||
timestamp = datetime.now().strftime("%H:%M")
|
||||
if not ep_path.exists():
|
||||
header = f"# {today_str}\n\n"
|
||||
@@ -299,7 +326,8 @@ async def consolidate_queen_memory(
|
||||
|
||||
existing_semantic = read_semantic_memory()
|
||||
today_journal = read_episodic_memory()
|
||||
today_str = date.today().strftime("%B %-d, %Y")
|
||||
today = date.today()
|
||||
today_str = f"{today.strftime('%B')} {today.day}, {today.year}"
|
||||
adapt_path = session_dir / "data" / "adapt.md"
|
||||
|
||||
user_msg = (
|
||||
|
||||
@@ -19,6 +19,10 @@ from framework.graph.edge import DEFAULT_MAX_TOKENS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HIVE_CONFIG_FILE = Path.home() / ".hive" / "configuration.json"
|
||||
|
||||
# Hive LLM router endpoint (Anthropic-compatible).
|
||||
# litellm's Anthropic handler appends /v1/messages, so this is just the base host.
|
||||
HIVE_LLM_ENDPOINT = "https://api.adenhq.com"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -142,13 +142,17 @@ def save_aden_api_key(key: str) -> None:
|
||||
os.environ[ADEN_ENV_VAR] = key
|
||||
|
||||
|
||||
def delete_aden_api_key() -> None:
|
||||
"""Remove ADEN_API_KEY from the encrypted store and ``os.environ``."""
|
||||
def delete_aden_api_key() -> bool:
|
||||
"""Remove ADEN_API_KEY from the encrypted store and ``os.environ``.
|
||||
|
||||
Returns True if the key existed and was deleted, False otherwise.
|
||||
"""
|
||||
deleted = False
|
||||
try:
|
||||
from .storage import EncryptedFileStorage
|
||||
|
||||
storage = EncryptedFileStorage()
|
||||
storage.delete(ADEN_CREDENTIAL_ID)
|
||||
deleted = storage.delete(ADEN_CREDENTIAL_ID)
|
||||
except (FileNotFoundError, PermissionError) as e:
|
||||
logger.debug("Could not delete %s from encrypted store: %s", ADEN_CREDENTIAL_ID, e)
|
||||
except Exception:
|
||||
@@ -157,8 +161,8 @@ def delete_aden_api_key() -> None:
|
||||
ADEN_CREDENTIAL_ID,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
os.environ.pop(ADEN_ENV_VAR, None)
|
||||
return deleted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -202,6 +202,14 @@ class LoopConfig:
|
||||
max_tool_result_chars: int = 30_000
|
||||
spillover_dir: str | None = None # Path string; created on first use
|
||||
|
||||
# --- set_output value spilling ---
|
||||
# When a set_output value exceeds this character count it is auto-saved
|
||||
# to a file in *spillover_dir* and the stored value is replaced with a
|
||||
# lightweight file reference. This keeps shared memory / adapt.md /
|
||||
# transition markers small and forces the next node to load the full
|
||||
# data from the file. Set to 0 to disable.
|
||||
max_output_value_chars: int = 2_000
|
||||
|
||||
# --- Stream retry (transient error recovery within EventLoopNode) ---
|
||||
# When _run_single_turn() raises a transient error (network, rate limit,
|
||||
# server error), retry up to this many times with exponential backoff
|
||||
@@ -225,6 +233,18 @@ class LoopConfig:
|
||||
cf_grace_turns: int = 1
|
||||
tool_doom_loop_enabled: bool = True
|
||||
|
||||
# --- Per-tool-call timeout ---
|
||||
# Maximum seconds a single tool call may take before being killed.
|
||||
# Prevents hung MCP servers (especially browser/GCU tools) from
|
||||
# blocking the entire event loop indefinitely. 0 = no timeout.
|
||||
tool_call_timeout_seconds: float = 60.0
|
||||
|
||||
# --- Subagent delegation timeout ---
|
||||
# Maximum seconds a delegate_to_sub_agent call may run before being
|
||||
# killed. Subagents run a full event-loop so they naturally take
|
||||
# longer than a single tool call — default is 10 minutes. 0 = no timeout.
|
||||
subagent_timeout_seconds: float = 300.0
|
||||
|
||||
# --- Lifecycle hooks ---
|
||||
# Hooks are async callables keyed by event name. Supported events:
|
||||
# "session_start" — fires once after the first user message is added,
|
||||
@@ -473,6 +493,8 @@ class EventLoopNode(NodeProtocol):
|
||||
focus_prompt=ctx.node_spec.system_prompt,
|
||||
narrative=ctx.narrative or None,
|
||||
accounts_prompt=ctx.accounts_prompt or None,
|
||||
skills_catalog_prompt=ctx.skills_catalog_prompt or None,
|
||||
protocols_prompt=ctx.protocols_prompt or None,
|
||||
)
|
||||
if conversation.system_prompt != _current_prompt:
|
||||
conversation.update_system_prompt(_current_prompt)
|
||||
@@ -482,9 +504,21 @@ class EventLoopNode(NodeProtocol):
|
||||
_restored_tool_fingerprints = []
|
||||
|
||||
# Fresh conversation: either isolated mode or first node in continuous mode.
|
||||
from framework.graph.prompt_composer import _with_datetime
|
||||
from framework.graph.prompt_composer import (
|
||||
EXECUTION_SCOPE_PREAMBLE,
|
||||
_with_datetime,
|
||||
)
|
||||
|
||||
system_prompt = _with_datetime(ctx.node_spec.system_prompt or "")
|
||||
# Prepend execution-scope preamble for worker nodes so the
|
||||
# LLM knows it is one step in a pipeline and should not try
|
||||
# to perform work that belongs to other nodes.
|
||||
if (
|
||||
not ctx.is_subagent_mode
|
||||
and ctx.node_spec.node_type in ("event_loop", "gcu")
|
||||
and ctx.node_spec.output_keys
|
||||
):
|
||||
system_prompt = f"{EXECUTION_SCOPE_PREAMBLE}\n\n{system_prompt}"
|
||||
# Prepend GCU browser best-practices prompt for gcu nodes
|
||||
if ctx.node_spec.node_type == "gcu":
|
||||
from framework.graph.gcu import GCU_BROWSER_SYSTEM_PROMPT
|
||||
@@ -494,6 +528,22 @@ class EventLoopNode(NodeProtocol):
|
||||
if ctx.accounts_prompt:
|
||||
system_prompt = f"{system_prompt}\n\n{ctx.accounts_prompt}"
|
||||
|
||||
# Append skill catalog and operational protocols
|
||||
if ctx.skills_catalog_prompt:
|
||||
system_prompt = f"{system_prompt}\n\n{ctx.skills_catalog_prompt}"
|
||||
logger.info(
|
||||
"[%s] Injected skills catalog (%d chars)",
|
||||
node_id,
|
||||
len(ctx.skills_catalog_prompt),
|
||||
)
|
||||
if ctx.protocols_prompt:
|
||||
system_prompt = f"{system_prompt}\n\n{ctx.protocols_prompt}"
|
||||
logger.info(
|
||||
"[%s] Injected operational protocols (%d chars)",
|
||||
node_id,
|
||||
len(ctx.protocols_prompt),
|
||||
)
|
||||
|
||||
# Inject agent working memory (adapt.md).
|
||||
# If it doesn't exist yet, seed it with available context.
|
||||
if self._config.spillover_dir:
|
||||
@@ -575,10 +625,24 @@ class EventLoopNode(NodeProtocol):
|
||||
# - Node has sub_agents defined
|
||||
# - We are NOT in subagent mode (prevents nested delegation)
|
||||
if not ctx.is_subagent_mode:
|
||||
sub_agents = getattr(ctx.node_spec, "sub_agents", [])
|
||||
delegate_tool = self._build_delegate_tool(sub_agents, ctx.node_registry)
|
||||
if delegate_tool:
|
||||
tools.append(delegate_tool)
|
||||
sub_agents = getattr(ctx.node_spec, "sub_agents", None) or []
|
||||
if sub_agents:
|
||||
delegate_tool = self._build_delegate_tool(sub_agents, ctx.node_registry)
|
||||
if delegate_tool:
|
||||
tools.append(delegate_tool)
|
||||
logger.info(
|
||||
"[%s] delegate_to_sub_agent injected (sub_agents=%s)",
|
||||
node_id,
|
||||
sub_agents,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"[%s] _build_delegate_tool returned None for sub_agents=%s",
|
||||
node_id,
|
||||
sub_agents,
|
||||
)
|
||||
else:
|
||||
logger.debug("[%s] Skipped delegate tool (is_subagent_mode=True)", node_id)
|
||||
|
||||
# Add report_to_parent tool for sub-agents with a report callback
|
||||
if ctx.is_subagent_mode and ctx.report_callback is not None:
|
||||
@@ -1920,6 +1984,11 @@ class EventLoopNode(NodeProtocol):
|
||||
# Accumulate ALL tool calls across inner iterations for L3 logging.
|
||||
# Unlike real_tool_results (reset each inner iteration), this persists.
|
||||
logged_tool_calls: list[dict] = []
|
||||
# Counter for LLM calls within a single iteration. Each pass through
|
||||
# the inner tool loop starts a fresh LLM stream whose snapshot resets
|
||||
# to "". Without this, all calls share the same message ID on the
|
||||
# frontend and the second call's text silently replaces the first.
|
||||
inner_turn = 0
|
||||
|
||||
# Inner tool loop: stream may produce tool calls requiring re-invocation
|
||||
while True:
|
||||
@@ -1960,6 +2029,7 @@ class EventLoopNode(NodeProtocol):
|
||||
async def _do_stream(
|
||||
_msgs: list = messages, # noqa: B006
|
||||
_tc: list[ToolCallEvent] = tool_calls, # noqa: B006
|
||||
inner_turn: int = inner_turn,
|
||||
) -> None:
|
||||
nonlocal accumulated_text, _stream_error
|
||||
async for event in ctx.llm.stream(
|
||||
@@ -1978,6 +2048,7 @@ class EventLoopNode(NodeProtocol):
|
||||
ctx,
|
||||
execution_id,
|
||||
iteration=iteration,
|
||||
inner_turn=inner_turn,
|
||||
)
|
||||
|
||||
elif isinstance(event, ToolCallEvent):
|
||||
@@ -2137,6 +2208,57 @@ class EventLoopNode(NodeProtocol):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
key = tc.tool_input.get("key", "")
|
||||
|
||||
# Auto-spill: save large values to data files and
|
||||
# replace with a lightweight file reference so shared
|
||||
# memory / adapt.md / transition markers stay small.
|
||||
spill_dir = self._config.spillover_dir
|
||||
max_val = self._config.max_output_value_chars
|
||||
if max_val > 0 and spill_dir:
|
||||
val_str = (
|
||||
json.dumps(value, ensure_ascii=False)
|
||||
if not isinstance(value, str)
|
||||
else value
|
||||
)
|
||||
if len(val_str) > max_val:
|
||||
spill_path = Path(spill_dir)
|
||||
spill_path.mkdir(parents=True, exist_ok=True)
|
||||
ext = ".json" if isinstance(value, (dict, list)) else ".txt"
|
||||
filename = f"output_{key}{ext}"
|
||||
write_content = (
|
||||
json.dumps(value, indent=2, ensure_ascii=False)
|
||||
if isinstance(value, (dict, list))
|
||||
else str(value)
|
||||
)
|
||||
(spill_path / filename).write_text(write_content, encoding="utf-8")
|
||||
file_size = (spill_path / filename).stat().st_size
|
||||
logger.info(
|
||||
"set_output value auto-spilled: key=%s, "
|
||||
"%d chars → %s (%d bytes)",
|
||||
key,
|
||||
len(val_str),
|
||||
filename,
|
||||
file_size,
|
||||
)
|
||||
# Replace value with reference
|
||||
value = (
|
||||
f"[Saved to '{filename}' ({file_size:,} bytes). "
|
||||
f"Use load_data(filename='{filename}') "
|
||||
f"to access full data.]"
|
||||
)
|
||||
# Update tool result to inform the LLM
|
||||
result = ToolResult(
|
||||
tool_use_id=tc.tool_use_id,
|
||||
content=(
|
||||
f"Output '{key}' was large "
|
||||
f"({len(val_str):,} chars) — data saved "
|
||||
f"to '{filename}' ({file_size:,} bytes). "
|
||||
f"The next phase will see the file "
|
||||
f"reference and can load full data."
|
||||
),
|
||||
is_error=False,
|
||||
)
|
||||
|
||||
await accumulator.set(key, value)
|
||||
self._record_learning(key, value)
|
||||
outputs_set_this_turn.append(key)
|
||||
@@ -2206,6 +2328,7 @@ class EventLoopNode(NodeProtocol):
|
||||
ctx=ctx,
|
||||
execution_id=execution_id,
|
||||
iteration=iteration,
|
||||
inner_turn=inner_turn,
|
||||
)
|
||||
|
||||
result = ToolResult(
|
||||
@@ -2439,21 +2562,44 @@ class EventLoopNode(NodeProtocol):
|
||||
|
||||
# Phase 2b: execute subagent delegations in parallel.
|
||||
if pending_subagent:
|
||||
_subagent_timeout = self._config.subagent_timeout_seconds
|
||||
|
||||
async def _timed_subagent(
|
||||
_ctx: NodeContext,
|
||||
_tc: ToolCallEvent,
|
||||
_acc: OutputAccumulator = accumulator,
|
||||
_timeout: float = _subagent_timeout,
|
||||
) -> tuple[ToolResult | BaseException, str, float]:
|
||||
_s = time.time()
|
||||
_iso = datetime.now(UTC).isoformat()
|
||||
try:
|
||||
_r = await self._execute_subagent(
|
||||
_coro = self._execute_subagent(
|
||||
_ctx,
|
||||
_tc.tool_input.get("agent_id", ""),
|
||||
_tc.tool_input.get("task", ""),
|
||||
accumulator=_acc,
|
||||
)
|
||||
if _timeout > 0:
|
||||
_r = await asyncio.wait_for(_coro, timeout=_timeout)
|
||||
else:
|
||||
_r = await _coro
|
||||
except TimeoutError:
|
||||
_agent_id = _tc.tool_input.get("agent_id", "unknown")
|
||||
logger.warning(
|
||||
"Subagent '%s' timed out after %.0fs",
|
||||
_agent_id,
|
||||
_timeout,
|
||||
)
|
||||
_r = ToolResult(
|
||||
tool_use_id=_tc.tool_use_id,
|
||||
content=(
|
||||
f"Subagent '{_agent_id}' timed out after "
|
||||
f"{_timeout:.0f}s. The delegation took "
|
||||
"too long and was cancelled. Try a simpler task "
|
||||
"or break it into smaller pieces."
|
||||
),
|
||||
is_error=True,
|
||||
)
|
||||
except BaseException as _exc:
|
||||
_r = _exc
|
||||
_dur = round(time.time() - _s, 3)
|
||||
@@ -2659,6 +2805,7 @@ class EventLoopNode(NodeProtocol):
|
||||
)
|
||||
|
||||
# Tool calls processed -- loop back to stream with updated conversation
|
||||
inner_turn += 1
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Synthetic tools: set_output, ask_user, escalate
|
||||
@@ -2795,6 +2942,12 @@ class EventLoopNode(NodeProtocol):
|
||||
name="set_output",
|
||||
description=(
|
||||
"Set an output value for this node. Call once per output key. "
|
||||
"Use this for brief notes, counts, status, and file references — "
|
||||
"NOT for large data payloads. When a tool result was saved to a "
|
||||
"data file, pass the filename as the value "
|
||||
"(e.g. 'google_sheets_get_values_1.txt') so the next phase can "
|
||||
"load the full data. Values exceeding ~2000 characters are "
|
||||
"auto-saved to data files. "
|
||||
f"Valid keys: {output_keys}"
|
||||
),
|
||||
parameters={
|
||||
@@ -2807,7 +2960,10 @@ class EventLoopNode(NodeProtocol):
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The output value to store.",
|
||||
"description": (
|
||||
"The output value — a brief note, count, status, "
|
||||
"or data filename reference."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["key", "value"],
|
||||
@@ -3331,7 +3487,14 @@ class EventLoopNode(NodeProtocol):
|
||||
return False, ""
|
||||
|
||||
async def _execute_tool(self, tc: ToolCallEvent) -> ToolResult:
|
||||
"""Execute a tool call, handling both sync and async executors."""
|
||||
"""Execute a tool call, handling both sync and async executors.
|
||||
|
||||
Applies ``tool_call_timeout_seconds`` from LoopConfig to prevent
|
||||
hung MCP servers from blocking the event loop indefinitely.
|
||||
The initial executor call is offloaded to a thread pool so that
|
||||
sync executors (MCP STDIO tools that block on ``future.result()``)
|
||||
don't freeze the event loop.
|
||||
"""
|
||||
if self._tool_executor is None:
|
||||
return ToolResult(
|
||||
tool_use_id=tc.tool_use_id,
|
||||
@@ -3339,9 +3502,35 @@ class EventLoopNode(NodeProtocol):
|
||||
is_error=True,
|
||||
)
|
||||
tool_use = ToolUse(id=tc.tool_use_id, name=tc.tool_name, input=tc.tool_input)
|
||||
result = self._tool_executor(tool_use)
|
||||
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
|
||||
result = await result
|
||||
timeout = self._config.tool_call_timeout_seconds
|
||||
|
||||
async def _run() -> ToolResult:
|
||||
# Offload the executor call to a thread. Sync MCP executors
|
||||
# block on future.result() — running in a thread keeps the
|
||||
# event loop free so asyncio.wait_for can fire the timeout.
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, self._tool_executor, tool_use)
|
||||
# Async executors return a coroutine — await it on the loop
|
||||
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
|
||||
result = await result
|
||||
return result
|
||||
|
||||
try:
|
||||
if timeout > 0:
|
||||
result = await asyncio.wait_for(_run(), timeout=timeout)
|
||||
else:
|
||||
result = await _run()
|
||||
except TimeoutError:
|
||||
logger.warning("Tool '%s' timed out after %.0fs", tc.tool_name, timeout)
|
||||
return ToolResult(
|
||||
tool_use_id=tc.tool_use_id,
|
||||
content=(
|
||||
f"Tool '{tc.tool_name}' timed out after {timeout:.0f}s. "
|
||||
"The operation took too long and was cancelled. "
|
||||
"Try a simpler request or a different approach."
|
||||
),
|
||||
is_error=True,
|
||||
)
|
||||
return result
|
||||
|
||||
def _record_learning(self, key: str, value: Any) -> None:
|
||||
@@ -3412,6 +3601,125 @@ class EventLoopNode(NodeProtocol):
|
||||
self._spill_counter = max_n
|
||||
logger.info("Restored spill counter to %d from existing files", max_n)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON metadata / smart preview helpers for truncation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_json_metadata(parsed: Any, *, _depth: int = 0, _max_depth: int = 3) -> str:
|
||||
"""Return a concise structural summary of parsed JSON.
|
||||
|
||||
Reports key names, value types, and — crucially — array lengths so
|
||||
the LLM knows how much data exists beyond the preview.
|
||||
|
||||
Returns an empty string for simple scalars.
|
||||
"""
|
||||
if _depth >= _max_depth:
|
||||
if isinstance(parsed, dict):
|
||||
return f"dict with {len(parsed)} keys"
|
||||
if isinstance(parsed, list):
|
||||
return f"list of {len(parsed)} items"
|
||||
return type(parsed).__name__
|
||||
|
||||
if isinstance(parsed, dict):
|
||||
if not parsed:
|
||||
return "empty dict"
|
||||
lines: list[str] = []
|
||||
indent = " " * (_depth + 1)
|
||||
for key, value in list(parsed.items())[:20]:
|
||||
if isinstance(value, list):
|
||||
line = f'{indent}"{key}": list of {len(value)} items'
|
||||
if value:
|
||||
first = value[0]
|
||||
if isinstance(first, dict):
|
||||
sample_keys = list(first.keys())[:10]
|
||||
line += f" (each item: dict with keys {sample_keys})"
|
||||
elif isinstance(first, list):
|
||||
line += f" (each item: list of {len(first)} elements)"
|
||||
lines.append(line)
|
||||
elif isinstance(value, dict):
|
||||
child = EventLoopNode._extract_json_metadata(
|
||||
value, _depth=_depth + 1, _max_depth=_max_depth
|
||||
)
|
||||
lines.append(f'{indent}"{key}": {child}')
|
||||
else:
|
||||
lines.append(f'{indent}"{key}": {type(value).__name__}')
|
||||
if len(parsed) > 20:
|
||||
lines.append(f"{indent}... and {len(parsed) - 20} more keys")
|
||||
return "\n".join(lines)
|
||||
|
||||
if isinstance(parsed, list):
|
||||
if not parsed:
|
||||
return "empty list"
|
||||
desc = f"list of {len(parsed)} items"
|
||||
first = parsed[0]
|
||||
if isinstance(first, dict):
|
||||
sample_keys = list(first.keys())[:10]
|
||||
desc += f" (each item: dict with keys {sample_keys})"
|
||||
elif isinstance(first, list):
|
||||
desc += f" (each item: list of {len(first)} elements)"
|
||||
return desc
|
||||
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_json_preview(parsed: Any, *, max_chars: int = 5000) -> str | None:
|
||||
"""Build a smart preview of parsed JSON, truncating large arrays.
|
||||
|
||||
Shows first 3 + last 1 items of large arrays with explicit count
|
||||
markers so the LLM cannot mistake the preview for the full dataset.
|
||||
|
||||
Returns ``None`` if no truncation was needed (no large arrays).
|
||||
"""
|
||||
_LARGE_ARRAY_THRESHOLD = 10
|
||||
|
||||
def _truncate_arrays(obj: Any) -> tuple[Any, bool]:
|
||||
"""Return (truncated_copy, was_truncated)."""
|
||||
if isinstance(obj, list) and len(obj) > _LARGE_ARRAY_THRESHOLD:
|
||||
n = len(obj)
|
||||
head = obj[:3]
|
||||
tail = obj[-1:]
|
||||
marker = f"... ({n - 4} more items omitted, {n} total) ..."
|
||||
return head + [marker] + tail, True
|
||||
if isinstance(obj, dict):
|
||||
changed = False
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in obj.items():
|
||||
new_v, did = _truncate_arrays(v)
|
||||
out[k] = new_v
|
||||
changed = changed or did
|
||||
return (out, True) if changed else (obj, False)
|
||||
return obj, False
|
||||
|
||||
preview_obj, was_truncated = _truncate_arrays(parsed)
|
||||
if not was_truncated:
|
||||
return None # No large arrays — caller should use raw slicing
|
||||
|
||||
try:
|
||||
result = json.dumps(preview_obj, indent=2, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if len(result) > max_chars:
|
||||
# Even 3+1 items too big — try just 1 item
|
||||
def _minimal_arrays(obj: Any) -> Any:
|
||||
if isinstance(obj, list) and len(obj) > _LARGE_ARRAY_THRESHOLD:
|
||||
n = len(obj)
|
||||
return obj[:1] + [f"... ({n - 1} more items omitted, {n} total) ..."]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _minimal_arrays(v) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
preview_obj = _minimal_arrays(parsed)
|
||||
try:
|
||||
result = json.dumps(preview_obj, indent=2, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if len(result) > max_chars:
|
||||
result = result[:max_chars] + "…"
|
||||
|
||||
return result
|
||||
|
||||
def _truncate_tool_result(
|
||||
self,
|
||||
result: ToolResult,
|
||||
@@ -3440,15 +3748,36 @@ class EventLoopNode(NodeProtocol):
|
||||
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 = (
|
||||
f"[{tool_name} result: {len(result.content)} chars — "
|
||||
f"too large for context. Use offset/limit parameters "
|
||||
f"to read smaller chunks.]\n\n"
|
||||
f"Preview:\n{preview}…"
|
||||
# Large load_data result — truncate with smart preview
|
||||
PREVIEW_CAP = min(5000, max(limit - 500, limit // 2))
|
||||
|
||||
metadata_str = ""
|
||||
smart_preview: str | None = None
|
||||
try:
|
||||
parsed_ld = json.loads(result.content)
|
||||
metadata_str = self._extract_json_metadata(parsed_ld)
|
||||
smart_preview = self._build_json_preview(parsed_ld, max_chars=PREVIEW_CAP)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if smart_preview is not None:
|
||||
preview_block = smart_preview
|
||||
else:
|
||||
preview_block = result.content[:PREVIEW_CAP] + "…"
|
||||
|
||||
header = (
|
||||
f"[{tool_name} result: {len(result.content):,} chars — "
|
||||
f"too large for context. Use offset_bytes/limit_bytes "
|
||||
f"parameters to read smaller chunks.]"
|
||||
)
|
||||
if metadata_str:
|
||||
header += f"\n\nData structure:\n{metadata_str}"
|
||||
header += (
|
||||
"\n\nWARNING: This is an INCOMPLETE preview. "
|
||||
"Do NOT draw conclusions or counts from it."
|
||||
)
|
||||
|
||||
truncated = f"{header}\n\nPreview (small sample only):\n{preview_block}"
|
||||
logger.info(
|
||||
"%s result truncated: %d → %d chars (use offset/limit to paginate)",
|
||||
tool_name,
|
||||
@@ -3470,25 +3799,47 @@ class EventLoopNode(NodeProtocol):
|
||||
# Pretty-print JSON content so load_data's line-based
|
||||
# pagination works correctly.
|
||||
write_content = result.content
|
||||
parsed_json: Any = None # track for metadata extraction
|
||||
try:
|
||||
parsed = json.loads(result.content)
|
||||
write_content = json.dumps(parsed, indent=2, ensure_ascii=False)
|
||||
parsed_json = json.loads(result.content)
|
||||
write_content = json.dumps(parsed_json, indent=2, ensure_ascii=False)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass # Not JSON — write as-is
|
||||
|
||||
(spill_path / filename).write_text(write_content, encoding="utf-8")
|
||||
|
||||
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}…"
|
||||
# Large result: build a small, metadata-rich preview so the
|
||||
# LLM cannot mistake it for the complete dataset.
|
||||
PREVIEW_CAP = 5000
|
||||
|
||||
# Extract structural metadata (array lengths, key names)
|
||||
metadata_str = ""
|
||||
smart_preview: str | None = None
|
||||
if parsed_json is not None:
|
||||
metadata_str = self._extract_json_metadata(parsed_json)
|
||||
smart_preview = self._build_json_preview(parsed_json, max_chars=PREVIEW_CAP)
|
||||
|
||||
if smart_preview is not None:
|
||||
preview_block = smart_preview
|
||||
else:
|
||||
preview_block = result.content[:PREVIEW_CAP] + "…"
|
||||
|
||||
# Assemble header with structural info + warning
|
||||
header = (
|
||||
f"[Result from {tool_name}: {len(result.content):,} chars — "
|
||||
f"too large for context, saved to '{filename}'.]"
|
||||
)
|
||||
if metadata_str:
|
||||
header += f"\n\nData structure:\n{metadata_str}"
|
||||
header += (
|
||||
f"\n\nWARNING: The preview below is INCOMPLETE. "
|
||||
f"Do NOT draw conclusions or counts from it. "
|
||||
f"Use load_data(filename='{filename}') to read the "
|
||||
f"full data before analysis."
|
||||
)
|
||||
|
||||
content = f"{header}\n\nPreview (small sample only):\n{preview_block}"
|
||||
logger.info(
|
||||
"Tool result spilled to file: %s (%d chars → %s)",
|
||||
tool_name,
|
||||
@@ -3513,13 +3864,34 @@ class EventLoopNode(NodeProtocol):
|
||||
|
||||
# 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 "
|
||||
f"{preview_chars} chars are shown.]\n\n{preview}…"
|
||||
PREVIEW_CAP = min(5000, max(limit - 500, limit // 2))
|
||||
|
||||
metadata_str = ""
|
||||
smart_preview: str | None = None
|
||||
try:
|
||||
parsed_inline = json.loads(result.content)
|
||||
metadata_str = self._extract_json_metadata(parsed_inline)
|
||||
smart_preview = self._build_json_preview(parsed_inline, max_chars=PREVIEW_CAP)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if smart_preview is not None:
|
||||
preview_block = smart_preview
|
||||
else:
|
||||
preview_block = result.content[:PREVIEW_CAP] + "…"
|
||||
|
||||
header = (
|
||||
f"[Result from {tool_name}: {len(result.content):,} chars — "
|
||||
f"truncated to fit context budget.]"
|
||||
)
|
||||
if metadata_str:
|
||||
header += f"\n\nData structure:\n{metadata_str}"
|
||||
header += (
|
||||
"\n\nWARNING: This is an INCOMPLETE preview. "
|
||||
"Do NOT draw conclusions or counts from the preview alone."
|
||||
)
|
||||
|
||||
truncated = f"{header}\n\n{preview_block}"
|
||||
logger.info(
|
||||
"Tool result truncated in-place: %s (%d → %d chars)",
|
||||
tool_name,
|
||||
@@ -4344,6 +4716,7 @@ class EventLoopNode(NodeProtocol):
|
||||
ctx: NodeContext,
|
||||
execution_id: str = "",
|
||||
iteration: int | None = None,
|
||||
inner_turn: int = 0,
|
||||
) -> None:
|
||||
if self._event_bus:
|
||||
if ctx.node_spec.client_facing:
|
||||
@@ -4354,6 +4727,7 @@ class EventLoopNode(NodeProtocol):
|
||||
snapshot=snapshot,
|
||||
execution_id=execution_id,
|
||||
iteration=iteration,
|
||||
inner_turn=inner_turn,
|
||||
)
|
||||
else:
|
||||
await self._event_bus.emit_llm_text_delta(
|
||||
@@ -4362,6 +4736,7 @@ class EventLoopNode(NodeProtocol):
|
||||
content=content,
|
||||
snapshot=snapshot,
|
||||
execution_id=execution_id,
|
||||
inner_turn=inner_turn,
|
||||
)
|
||||
|
||||
async def _publish_tool_started(
|
||||
@@ -4591,11 +4966,19 @@ class EventLoopNode(NodeProtocol):
|
||||
subagent_tool_names = set(subagent_spec.tools or [])
|
||||
tool_source = ctx.all_tools if ctx.all_tools else ctx.available_tools
|
||||
|
||||
subagent_tools = [
|
||||
t
|
||||
for t in tool_source
|
||||
if t.name in subagent_tool_names and t.name != "delegate_to_sub_agent"
|
||||
]
|
||||
# GCU auto-population: GCU nodes declare tools=[] because the runner
|
||||
# auto-populates them at setup time. But that expansion doesn't reach
|
||||
# subagents invoked via delegate_to_sub_agent — the subagent spec still
|
||||
# has the original empty list. When a GCU subagent has no declared
|
||||
# tools, include all catalog tools so browser tools are available.
|
||||
if subagent_spec.node_type == "gcu" and not subagent_tool_names:
|
||||
subagent_tools = [t for t in tool_source if t.name != "delegate_to_sub_agent"]
|
||||
else:
|
||||
subagent_tools = [
|
||||
t
|
||||
for t in tool_source
|
||||
if t.name in subagent_tool_names and t.name != "delegate_to_sub_agent"
|
||||
]
|
||||
|
||||
missing = subagent_tool_names - {t.name for t in subagent_tools}
|
||||
if missing:
|
||||
|
||||
@@ -27,11 +27,14 @@ from framework.graph.node import (
|
||||
SharedMemory,
|
||||
)
|
||||
from framework.graph.validator import OutputValidator
|
||||
from framework.llm.provider import LLMProvider, Tool
|
||||
from framework.llm.provider import LLMProvider, Tool, ToolUse
|
||||
from framework.observability import set_trace_context
|
||||
from framework.runtime.core import Runtime
|
||||
from framework.schemas.checkpoint import Checkpoint
|
||||
from framework.storage.checkpoint_store import CheckpointStore
|
||||
from framework.utils.io import atomic_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _default_max_context_tokens() -> int:
|
||||
@@ -149,6 +152,8 @@ class GraphExecutor:
|
||||
dynamic_tools_provider: Callable | None = None,
|
||||
dynamic_prompt_provider: Callable | None = None,
|
||||
iteration_metadata_provider: Callable | None = None,
|
||||
skills_catalog_prompt: str = "",
|
||||
protocols_prompt: str = "",
|
||||
):
|
||||
"""
|
||||
Initialize the executor.
|
||||
@@ -174,6 +179,8 @@ class GraphExecutor:
|
||||
tool list (for mode switching)
|
||||
dynamic_prompt_provider: Optional callback returning current
|
||||
system prompt (for phase switching)
|
||||
skills_catalog_prompt: Available skills catalog for system prompt
|
||||
protocols_prompt: Default skill operational protocols for system prompt
|
||||
"""
|
||||
self.runtime = runtime
|
||||
self.llm = llm
|
||||
@@ -195,6 +202,20 @@ class GraphExecutor:
|
||||
self.dynamic_tools_provider = dynamic_tools_provider
|
||||
self.dynamic_prompt_provider = dynamic_prompt_provider
|
||||
self.iteration_metadata_provider = iteration_metadata_provider
|
||||
self.skills_catalog_prompt = skills_catalog_prompt
|
||||
self.protocols_prompt = protocols_prompt
|
||||
|
||||
if protocols_prompt:
|
||||
self.logger.info(
|
||||
"GraphExecutor[%s] received protocols_prompt (%d chars)",
|
||||
stream_id,
|
||||
len(protocols_prompt),
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"GraphExecutor[%s] received EMPTY protocols_prompt",
|
||||
stream_id,
|
||||
)
|
||||
|
||||
# Parallel execution settings
|
||||
self.enable_parallel_execution = enable_parallel_execution
|
||||
@@ -224,11 +245,11 @@ class GraphExecutor:
|
||||
"""
|
||||
if not self._storage_path:
|
||||
return
|
||||
state_path = self._storage_path / "state.json"
|
||||
try:
|
||||
import json as _json
|
||||
from datetime import datetime
|
||||
|
||||
state_path = self._storage_path / "state.json"
|
||||
if state_path.exists():
|
||||
state_data = _json.loads(state_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
@@ -251,9 +272,14 @@ class GraphExecutor:
|
||||
state_data["memory"] = memory_snapshot
|
||||
state_data["memory_keys"] = list(memory_snapshot.keys())
|
||||
|
||||
state_path.write_text(_json.dumps(state_data, indent=2), encoding="utf-8")
|
||||
with atomic_write(state_path, encoding="utf-8") as f:
|
||||
_json.dump(state_data, f, indent=2)
|
||||
except Exception:
|
||||
pass # Best-effort — never block execution
|
||||
logger.warning(
|
||||
"Failed to persist progress state to %s",
|
||||
state_path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _validate_tools(self, graph: GraphSpec) -> list[str]:
|
||||
"""
|
||||
@@ -415,6 +441,14 @@ class GraphExecutor:
|
||||
)
|
||||
return s1 + "\n\n" + s2
|
||||
|
||||
def _get_runtime_log_session_id(self) -> str:
|
||||
"""Return the session-backed execution ID for runtime logging, if any."""
|
||||
if not self._storage_path:
|
||||
return ""
|
||||
if self._storage_path.parent.name != "sessions":
|
||||
return ""
|
||||
return self._storage_path.name
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
graph: GraphSpec,
|
||||
@@ -708,10 +742,7 @@ class GraphExecutor:
|
||||
)
|
||||
|
||||
if self.runtime_logger:
|
||||
# Extract session_id from storage_path if available (for unified sessions)
|
||||
session_id = ""
|
||||
if self._storage_path and self._storage_path.name.startswith("session_"):
|
||||
session_id = self._storage_path.name
|
||||
session_id = self._get_runtime_log_session_id()
|
||||
self.runtime_logger.start_run(goal_id=goal.id, session_id=session_id)
|
||||
|
||||
self.logger.info(f"🚀 Starting execution: {goal.name}")
|
||||
@@ -937,6 +968,33 @@ class GraphExecutor:
|
||||
self.logger.info(" Executing...")
|
||||
result = await node_impl.execute(ctx)
|
||||
|
||||
# GCU tab cleanup: stop the browser profile after a top-level GCU node
|
||||
# finishes so tabs don't accumulate. Mirrors the subagent cleanup in
|
||||
# EventLoopNode._execute_subagent().
|
||||
if node_spec.node_type == "gcu" and self.tool_executor is not None:
|
||||
try:
|
||||
from gcu.browser.session import (
|
||||
_active_profile as _gcu_profile_var,
|
||||
)
|
||||
|
||||
_gcu_profile = _gcu_profile_var.get()
|
||||
_stop_use = ToolUse(
|
||||
id="gcu-cleanup",
|
||||
name="browser_stop",
|
||||
input={"profile": _gcu_profile},
|
||||
)
|
||||
_stop_result = self.tool_executor(_stop_use)
|
||||
if asyncio.iscoroutine(_stop_result) or asyncio.isfuture(_stop_result):
|
||||
await _stop_result
|
||||
except ImportError:
|
||||
pass # GCU not installed
|
||||
except Exception as _gcu_exc:
|
||||
logger.warning(
|
||||
"GCU browser_stop failed for profile %r: %s",
|
||||
_gcu_profile,
|
||||
_gcu_exc,
|
||||
)
|
||||
|
||||
# Emit node-completed event (skip event_loop nodes)
|
||||
if self._event_bus and node_spec.node_type != "event_loop":
|
||||
await self._event_bus.emit_node_loop_completed(
|
||||
@@ -1362,6 +1420,7 @@ class GraphExecutor:
|
||||
next_spec = graph.get_node(current_node_id)
|
||||
if next_spec and next_spec.node_type == "event_loop":
|
||||
from framework.graph.prompt_composer import (
|
||||
EXECUTION_SCOPE_PREAMBLE,
|
||||
build_accounts_prompt,
|
||||
build_narrative,
|
||||
build_transition_marker,
|
||||
@@ -1401,9 +1460,14 @@ class GraphExecutor:
|
||||
)
|
||||
|
||||
# Compose new system prompt (Layer 1 + 2 + 3 + accounts)
|
||||
# Prepend scope preamble to focus so the LLM stays
|
||||
# within this node's responsibility.
|
||||
_focus = next_spec.system_prompt
|
||||
if next_spec.output_keys and _focus:
|
||||
_focus = f"{EXECUTION_SCOPE_PREAMBLE}\n\n{_focus}"
|
||||
new_system = compose_system_prompt(
|
||||
identity_prompt=getattr(graph, "identity_prompt", None),
|
||||
focus_prompt=next_spec.system_prompt,
|
||||
focus_prompt=_focus,
|
||||
narrative=narrative,
|
||||
accounts_prompt=_node_accounts,
|
||||
)
|
||||
@@ -1765,10 +1829,31 @@ class GraphExecutor:
|
||||
if node_spec.tools:
|
||||
available_tools = [t for t in self.tools if t.name in node_spec.tools]
|
||||
|
||||
# Create scoped memory view
|
||||
# Create scoped memory view.
|
||||
# When permissions are restricted (non-empty key lists), auto-include
|
||||
# _-prefixed keys used by default skill protocols so agents can read/write
|
||||
# operational state (e.g. _working_notes, _batch_ledger) regardless of
|
||||
# what the node declares. When key lists are empty (unrestricted), leave
|
||||
# unchanged — empty means "allow all".
|
||||
read_keys = list(node_spec.input_keys)
|
||||
write_keys = list(node_spec.output_keys)
|
||||
# Only extend lists that were already restricted (non-empty).
|
||||
# Empty means "allow all" — adding keys would accidentally
|
||||
# activate the permission check and block legitimate reads/writes.
|
||||
if read_keys or write_keys:
|
||||
from framework.skills.defaults import SHARED_MEMORY_KEYS as _skill_keys
|
||||
|
||||
existing_underscore = [k for k in memory._data if k.startswith("_")]
|
||||
extra_keys = set(_skill_keys) | set(existing_underscore)
|
||||
for k in extra_keys:
|
||||
if read_keys and k not in read_keys:
|
||||
read_keys.append(k)
|
||||
if write_keys and k not in write_keys:
|
||||
write_keys.append(k)
|
||||
|
||||
scoped_memory = memory.with_permissions(
|
||||
read_keys=node_spec.input_keys,
|
||||
write_keys=node_spec.output_keys,
|
||||
read_keys=read_keys,
|
||||
write_keys=write_keys,
|
||||
)
|
||||
|
||||
# Build per-node accounts prompt (filtered to this node's tools)
|
||||
@@ -1812,6 +1897,8 @@ class GraphExecutor:
|
||||
dynamic_tools_provider=self.dynamic_tools_provider,
|
||||
dynamic_prompt_provider=self.dynamic_prompt_provider,
|
||||
iteration_metadata_provider=self.iteration_metadata_provider,
|
||||
skills_catalog_prompt=self.skills_catalog_prompt,
|
||||
protocols_prompt=self.protocols_prompt,
|
||||
)
|
||||
|
||||
VALID_NODE_TYPES = {
|
||||
@@ -2052,6 +2139,10 @@ class GraphExecutor:
|
||||
edge=edge,
|
||||
)
|
||||
|
||||
# Track which branch wrote which key for memory conflict detection
|
||||
fanout_written_keys: dict[str, str] = {} # key -> branch_id that wrote it
|
||||
fanout_keys_lock = asyncio.Lock()
|
||||
|
||||
self.logger.info(f" ⑂ Fan-out: executing {len(branches)} branches in parallel")
|
||||
for branch in branches.values():
|
||||
target_spec = graph.get_node(branch.node_id)
|
||||
@@ -2143,8 +2234,31 @@ class GraphExecutor:
|
||||
)
|
||||
|
||||
if result.success:
|
||||
# Write outputs to shared memory using async write
|
||||
# Write outputs to shared memory with conflict detection
|
||||
conflict_strategy = self._parallel_config.memory_conflict_strategy
|
||||
for key, value in result.output.items():
|
||||
async with fanout_keys_lock:
|
||||
prior_branch = fanout_written_keys.get(key)
|
||||
if prior_branch and prior_branch != branch.branch_id:
|
||||
if conflict_strategy == "error":
|
||||
raise RuntimeError(
|
||||
f"Memory conflict: key '{key}' already written "
|
||||
f"by branch '{prior_branch}', "
|
||||
f"conflicting write from '{branch.branch_id}'"
|
||||
)
|
||||
elif conflict_strategy == "first_wins":
|
||||
self.logger.debug(
|
||||
f" ⚠ Skipping write to '{key}' "
|
||||
f"(first_wins: already set by {prior_branch})"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# last_wins (default): write and log
|
||||
self.logger.debug(
|
||||
f" ⚠ Key '{key}' overwritten "
|
||||
f"(last_wins: {prior_branch} -> {branch.branch_id})"
|
||||
)
|
||||
fanout_written_keys[key] = branch.branch_id
|
||||
await memory.write_async(key, value)
|
||||
|
||||
branch.result = result
|
||||
@@ -2191,9 +2305,11 @@ class GraphExecutor:
|
||||
|
||||
return branch, e
|
||||
|
||||
# Execute all branches concurrently
|
||||
tasks = [execute_single_branch(b) for b in branches.values()]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=False)
|
||||
# Execute all branches concurrently with per-branch timeout
|
||||
timeout = self._parallel_config.branch_timeout_seconds
|
||||
branch_list = list(branches.values())
|
||||
tasks = [asyncio.wait_for(execute_single_branch(b), timeout=timeout) for b in branch_list]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Process results
|
||||
total_tokens = 0
|
||||
@@ -2201,17 +2317,33 @@ class GraphExecutor:
|
||||
branch_results: dict[str, NodeResult] = {}
|
||||
failed_branches: list[ParallelBranch] = []
|
||||
|
||||
for branch, result in results:
|
||||
path.append(branch.node_id)
|
||||
for i, result in enumerate(results):
|
||||
branch = branch_list[i]
|
||||
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, asyncio.TimeoutError):
|
||||
# Branch timed out
|
||||
branch.status = "timed_out"
|
||||
branch.error = f"Branch timed out after {timeout}s"
|
||||
self.logger.warning(
|
||||
f" ⏱ Branch {graph.get_node(branch.node_id).name}: "
|
||||
f"timed out after {timeout}s"
|
||||
)
|
||||
path.append(branch.node_id)
|
||||
failed_branches.append(branch)
|
||||
elif result is None or not result.success:
|
||||
elif isinstance(result, Exception):
|
||||
path.append(branch.node_id)
|
||||
failed_branches.append(branch)
|
||||
else:
|
||||
total_tokens += result.tokens_used
|
||||
total_latency += result.latency_ms
|
||||
branch_results[branch.branch_id] = result
|
||||
returned_branch, node_result = result
|
||||
path.append(returned_branch.node_id)
|
||||
if node_result is None or isinstance(node_result, Exception):
|
||||
failed_branches.append(returned_branch)
|
||||
elif not node_result.success:
|
||||
failed_branches.append(returned_branch)
|
||||
else:
|
||||
total_tokens += node_result.tokens_used
|
||||
total_latency += node_result.latency_ms
|
||||
branch_results[returned_branch.branch_id] = node_result
|
||||
|
||||
# Handle failures based on config
|
||||
if failed_branches:
|
||||
|
||||
@@ -565,6 +565,10 @@ class NodeContext:
|
||||
# staging / running) without restarting the conversation.
|
||||
dynamic_prompt_provider: Any = None # Callable[[], str] | None
|
||||
|
||||
# Skill system prompts — injected by the skill discovery pipeline
|
||||
skills_catalog_prompt: str = "" # Available skills XML catalog
|
||||
protocols_prompt: str = "" # Default skill operational protocols
|
||||
|
||||
# Per-iteration metadata provider — when set, EventLoopNode merges
|
||||
# the returned dict into node_loop_iteration event data. Used by
|
||||
# the queen to record the current phase per iteration.
|
||||
|
||||
@@ -26,6 +26,16 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Injected into every worker node's system prompt so the LLM understands
|
||||
# it is one step in a multi-node pipeline and should not overreach.
|
||||
EXECUTION_SCOPE_PREAMBLE = (
|
||||
"EXECUTION SCOPE: You are one node in a multi-step workflow graph. "
|
||||
"Focus ONLY on the task described in your instructions below. "
|
||||
"Call set_output() for each of your declared output keys, then stop. "
|
||||
"Do NOT attempt work that belongs to other nodes — the framework "
|
||||
"routes data between nodes automatically."
|
||||
)
|
||||
|
||||
|
||||
def _with_datetime(prompt: str) -> str:
|
||||
"""Append current datetime with local timezone to a system prompt."""
|
||||
@@ -140,14 +150,18 @@ def compose_system_prompt(
|
||||
focus_prompt: str | None,
|
||||
narrative: str | None = None,
|
||||
accounts_prompt: str | None = None,
|
||||
skills_catalog_prompt: str | None = None,
|
||||
protocols_prompt: str | None = None,
|
||||
) -> str:
|
||||
"""Compose the three-layer system prompt.
|
||||
"""Compose the multi-layer system prompt.
|
||||
|
||||
Args:
|
||||
identity_prompt: Layer 1 — static agent identity (from GraphSpec).
|
||||
focus_prompt: Layer 3 — per-node focus directive (from NodeSpec.system_prompt).
|
||||
narrative: Layer 2 — auto-generated from conversation state.
|
||||
accounts_prompt: Connected accounts block (sits between identity and narrative).
|
||||
skills_catalog_prompt: Available skills catalog XML (Agent Skills standard).
|
||||
protocols_prompt: Default skill operational protocols section.
|
||||
|
||||
Returns:
|
||||
Composed system prompt with all layers present, plus current datetime.
|
||||
@@ -162,6 +176,14 @@ def compose_system_prompt(
|
||||
if accounts_prompt:
|
||||
parts.append(f"\n{accounts_prompt}")
|
||||
|
||||
# Skills catalog (discovered skills available for activation)
|
||||
if skills_catalog_prompt:
|
||||
parts.append(f"\n{skills_catalog_prompt}")
|
||||
|
||||
# Operational protocols (default skill behavioral guidance)
|
||||
if protocols_prompt:
|
||||
parts.append(f"\n{protocols_prompt}")
|
||||
|
||||
# Layer 2: Narrative (what's happened so far)
|
||||
if narrative:
|
||||
parts.append(f"\n--- Context (what has happened so far) ---\n{narrative}")
|
||||
@@ -294,6 +316,12 @@ def build_transition_marker(
|
||||
# Next phase
|
||||
sections.append(f"\nNow entering: {next_node.name}")
|
||||
sections.append(f" {next_node.description}")
|
||||
if next_node.output_keys:
|
||||
sections.append(
|
||||
f"\nYour ONLY job in this phase: complete the task above and call "
|
||||
f"set_output() for {next_node.output_keys}. Do NOT do work that "
|
||||
f"belongs to later phases."
|
||||
)
|
||||
|
||||
# Reflection prompt (engineered metacognition)
|
||||
sections.append(
|
||||
|
||||
@@ -115,11 +115,23 @@ class SafeEvalVisitor(ast.NodeVisitor):
|
||||
return True
|
||||
|
||||
def visit_BoolOp(self, node: ast.BoolOp) -> Any:
|
||||
values = [self.visit(v) for v in node.values]
|
||||
# Short-circuit evaluation to match Python semantics.
|
||||
# Previously all operands were eagerly evaluated, which broke
|
||||
# guard patterns like: ``x is not None and x.get("key")``
|
||||
if isinstance(node.op, ast.And):
|
||||
return all(values)
|
||||
result = True
|
||||
for v in node.values:
|
||||
result = self.visit(v)
|
||||
if not result:
|
||||
return result
|
||||
return result
|
||||
elif isinstance(node.op, ast.Or):
|
||||
return any(values)
|
||||
result = False
|
||||
for v in node.values:
|
||||
result = self.visit(v)
|
||||
if result:
|
||||
return result
|
||||
return result
|
||||
raise ValueError(f"Boolean operator {type(node.op).__name__} is not allowed")
|
||||
|
||||
def visit_IfExp(self, node: ast.IfExp) -> Any:
|
||||
|
||||
@@ -23,6 +23,7 @@ except ImportError:
|
||||
litellm = None # type: ignore[assignment]
|
||||
RateLimitError = Exception # type: ignore[assignment, misc]
|
||||
|
||||
from framework.config import HIVE_LLM_ENDPOINT as HIVE_API_BASE
|
||||
from framework.llm.provider import LLMProvider, LLMResponse, Tool
|
||||
from framework.llm.stream_events import StreamEvent
|
||||
|
||||
@@ -45,6 +46,12 @@ def _patch_litellm_anthropic_oauth() -> None:
|
||||
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
|
||||
from litellm.types.llms.anthropic import ANTHROPIC_OAUTH_TOKEN_PREFIX
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Could not apply litellm Anthropic OAuth patch — litellm internals may have "
|
||||
"changed. Anthropic OAuth tokens (Claude Code subscriptions) may fail with 401. "
|
||||
"See BerriAI/litellm#19618. Current litellm version: %s",
|
||||
getattr(litellm, "__version__", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
original = AnthropicModelInfo.validate_environment
|
||||
@@ -86,10 +93,12 @@ def _patch_litellm_metadata_nonetype() -> None:
|
||||
"""
|
||||
import functools
|
||||
|
||||
patched_count = 0
|
||||
for fn_name in ("completion", "acompletion", "responses", "aresponses"):
|
||||
original = getattr(litellm, fn_name, None)
|
||||
if original is None:
|
||||
continue
|
||||
patched_count += 1
|
||||
if asyncio.iscoroutinefunction(original):
|
||||
|
||||
@functools.wraps(original)
|
||||
@@ -109,6 +118,14 @@ def _patch_litellm_metadata_nonetype() -> None:
|
||||
|
||||
setattr(litellm, fn_name, _sync_wrapper)
|
||||
|
||||
if patched_count == 0:
|
||||
logger.warning(
|
||||
"Could not apply litellm metadata=None patch — none of the expected entry "
|
||||
"points (completion, acompletion, responses, aresponses) were found. "
|
||||
"metadata=None TypeError may occur. Current litellm version: %s",
|
||||
getattr(litellm, "__version__", "unknown"),
|
||||
)
|
||||
|
||||
|
||||
if litellm is not None:
|
||||
_patch_litellm_anthropic_oauth()
|
||||
@@ -150,6 +167,10 @@ EMPTY_STREAM_RETRY_DELAY = 1.0 # seconds
|
||||
# Directory for dumping failed requests
|
||||
FAILED_REQUESTS_DIR = Path.home() / ".hive" / "failed_requests"
|
||||
|
||||
# Maximum number of dump files to retain in ~/.hive/failed_requests/.
|
||||
# Older files are pruned automatically to prevent unbounded disk growth.
|
||||
MAX_FAILED_REQUEST_DUMPS = 50
|
||||
|
||||
|
||||
def _estimate_tokens(model: str, messages: list[dict]) -> tuple[int, str]:
|
||||
"""Estimate token count for messages. Returns (token_count, method)."""
|
||||
@@ -166,6 +187,24 @@ def _estimate_tokens(model: str, messages: list[dict]) -> tuple[int, str]:
|
||||
return total_chars // 4, "estimate"
|
||||
|
||||
|
||||
def _prune_failed_request_dumps(max_files: int = MAX_FAILED_REQUEST_DUMPS) -> None:
|
||||
"""Remove oldest dump files when the count exceeds *max_files*.
|
||||
|
||||
Best-effort: never raises — a pruning failure must not break retry logic.
|
||||
"""
|
||||
try:
|
||||
all_dumps = sorted(
|
||||
FAILED_REQUESTS_DIR.glob("*.json"),
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
)
|
||||
excess = len(all_dumps) - max_files
|
||||
if excess > 0:
|
||||
for old_file in all_dumps[:excess]:
|
||||
old_file.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass # Best-effort — never block the caller
|
||||
|
||||
|
||||
def _dump_failed_request(
|
||||
model: str,
|
||||
kwargs: dict[str, Any],
|
||||
@@ -197,6 +236,9 @@ def _dump_failed_request(
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(dump_data, f, indent=2, default=str)
|
||||
|
||||
# Prune old dumps to prevent unbounded disk growth
|
||||
_prune_failed_request_dumps()
|
||||
|
||||
return str(filepath)
|
||||
|
||||
|
||||
@@ -358,6 +400,10 @@ class LiteLLMProvider(LLMProvider):
|
||||
# Strip a trailing /v1 in case the user's saved config has the old value.
|
||||
if api_base and api_base.rstrip("/").endswith("/v1"):
|
||||
api_base = api_base.rstrip("/")[:-3]
|
||||
elif model.lower().startswith("hive/"):
|
||||
model = "anthropic/" + model[len("hive/") :]
|
||||
if api_base and api_base.rstrip("/").endswith("/v1"):
|
||||
api_base = api_base.rstrip("/")[:-3]
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base or self._default_api_base_for_model(_original_model)
|
||||
@@ -387,6 +433,8 @@ class LiteLLMProvider(LLMProvider):
|
||||
return MINIMAX_API_BASE
|
||||
if model_lower.startswith("kimi/"):
|
||||
return KIMI_API_BASE
|
||||
if model_lower.startswith("hive/"):
|
||||
return HIVE_API_BASE
|
||||
return None
|
||||
|
||||
def _completion_with_rate_limit_retry(
|
||||
|
||||
@@ -83,18 +83,18 @@ configure_logging(level="INFO", format="auto")
|
||||
- Compact single-line format (easy to stream/parse)
|
||||
- All trace context fields included automatically
|
||||
|
||||
### Human-Readable Format (Development)
|
||||
### Human-Readable Format (Development / Terminal)
|
||||
|
||||
```
|
||||
[INFO ] [trace:12345678 | exec:a1b2c3d4 | agent:sales-agent] Starting agent execution
|
||||
[INFO ] [trace:12345678 | exec:a1b2c3d4 | agent:sales-agent] Processing input data [node_id:input-processor]
|
||||
[INFO ] [trace:12345678 | exec:a1b2c3d4 | agent:sales-agent] LLM call completed [latency_ms:1250] [tokens_used:450]
|
||||
[INFO ] [agent:sales-agent] Starting agent execution
|
||||
[INFO ] [agent:sales-agent] Processing input data [node_id:input-processor]
|
||||
[INFO ] [agent:sales-agent] LLM call completed [latency_ms:1250] [tokens_used:450]
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Color-coded log levels
|
||||
- Shortened IDs for readability (first 8 chars)
|
||||
- Context prefix shows trace correlation
|
||||
- Terminal output omits trace_id and execution_id for readability
|
||||
- For full traceability (e.g. debugging), use `ENV=production` to get JSON file logs with trace_id and execution_id
|
||||
|
||||
## Trace Context Fields
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ Structured logging with automatic trace context propagation.
|
||||
Key Features:
|
||||
- Zero developer friction: Standard logger.info() calls get automatic context
|
||||
- ContextVar-based propagation: Thread-safe and async-safe
|
||||
- Dual output modes: JSON for production, human-readable for development
|
||||
- Correlation IDs: trace_id follows entire request flow automatically
|
||||
- Dual output modes: JSON for production (full trace_id/execution_id), human-readable for terminal
|
||||
- Terminal omits trace_id/execution_id for readability
|
||||
- Use ENV=production for file logs with full traceability
|
||||
|
||||
Architecture:
|
||||
Runtime.start_run() → Generates trace_id, sets context once
|
||||
@@ -101,10 +102,11 @@ class StructuredFormatter(logging.Formatter):
|
||||
|
||||
class HumanReadableFormatter(logging.Formatter):
|
||||
"""
|
||||
Human-readable formatter for development.
|
||||
Human-readable formatter for development (terminal output).
|
||||
|
||||
Provides colorized logs with trace context for local debugging.
|
||||
Includes trace_id prefix for correlation - AUTOMATIC!
|
||||
Provides colorized logs for local debugging. Omits trace_id and execution_id
|
||||
from the terminal for readability; use ENV=production (JSON file logs) when
|
||||
traceability is needed.
|
||||
"""
|
||||
|
||||
COLORS = {
|
||||
@@ -118,18 +120,11 @@ class HumanReadableFormatter(logging.Formatter):
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record as human-readable string."""
|
||||
# Get trace context - AUTOMATIC!
|
||||
# Get trace context; omit trace_id and execution_id in terminal for readability
|
||||
context = trace_context.get() or {}
|
||||
trace_id = context.get("trace_id", "")
|
||||
execution_id = context.get("execution_id", "")
|
||||
agent_id = context.get("agent_id", "")
|
||||
|
||||
# Build context prefix
|
||||
prefix_parts = []
|
||||
if trace_id:
|
||||
prefix_parts.append(f"trace:{trace_id[:8]}")
|
||||
if execution_id:
|
||||
prefix_parts.append(f"exec:{execution_id[-8:]}")
|
||||
if agent_id:
|
||||
prefix_parts.append(f"agent:{agent_id}")
|
||||
|
||||
@@ -211,6 +206,10 @@ def configure_logging(
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(level.upper())
|
||||
|
||||
# Suppress noisy LiteLLM INFO logs (model/provider line + Provider List URL
|
||||
# printed on every single completion call). Warnings and errors still show.
|
||||
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
|
||||
|
||||
# When in JSON mode, configure known third-party loggers to use JSON formatter
|
||||
# This ensures libraries like LiteLLM, httpcore also output clean JSON
|
||||
if format == "json":
|
||||
|
||||
@@ -28,6 +28,7 @@ from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import AgentRuntime, AgentRuntimeConfig, create_agent_runtime
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
from framework.runtime.runtime_log_store import RuntimeLogStore
|
||||
from framework.tools.flowchart_utils import generate_fallback_flowchart
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.runner.protocol import AgentMessage, CapabilityResponse
|
||||
@@ -959,6 +960,12 @@ class AgentRunner:
|
||||
|
||||
graph = GraphSpec(**graph_kwargs)
|
||||
|
||||
# Generate flowchart.json if missing (for template/legacy agents)
|
||||
generate_fallback_flowchart(graph, goal, agent_path)
|
||||
# Read skill configuration from agent module
|
||||
agent_default_skills = getattr(agent_module, "default_skills", None)
|
||||
agent_skills = getattr(agent_module, "skills", None)
|
||||
|
||||
# Read runtime config (webhook settings, etc.) if defined
|
||||
agent_runtime_config = getattr(agent_module, "runtime_config", None)
|
||||
|
||||
@@ -970,7 +977,7 @@ class AgentRunner:
|
||||
configure_fn = getattr(agent_module, "configure_for_account", None)
|
||||
list_accts_fn = getattr(agent_module, "list_connected_accounts", None)
|
||||
|
||||
return cls(
|
||||
runner = cls(
|
||||
agent_path=agent_path,
|
||||
graph=graph,
|
||||
goal=goal,
|
||||
@@ -986,6 +993,10 @@ class AgentRunner:
|
||||
list_accounts=list_accts_fn,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
# Stash skill config for use in _setup()
|
||||
runner._agent_default_skills = agent_default_skills
|
||||
runner._agent_skills = agent_skills
|
||||
return runner
|
||||
|
||||
# Fallback: load from agent.json (legacy JSON-based agents)
|
||||
agent_json_path = agent_path / "agent.json"
|
||||
@@ -1003,7 +1014,10 @@ class AgentRunner:
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in agent export file: {agent_json_path}") from exc
|
||||
|
||||
return cls(
|
||||
# Generate flowchart.json if missing (for legacy JSON-based agents)
|
||||
generate_fallback_flowchart(graph, goal, agent_path)
|
||||
|
||||
runner = cls(
|
||||
agent_path=agent_path,
|
||||
graph=graph,
|
||||
goal=goal,
|
||||
@@ -1014,6 +1028,9 @@ class AgentRunner:
|
||||
skip_credential_validation=skip_credential_validation or False,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
runner._agent_default_skills = None
|
||||
runner._agent_skills = None
|
||||
return runner
|
||||
|
||||
def register_tool(
|
||||
self,
|
||||
@@ -1323,6 +1340,19 @@ class AgentRunner:
|
||||
except Exception:
|
||||
pass # Best-effort — agent works without account info
|
||||
|
||||
# Skill configuration — the runtime handles discovery, loading, and
|
||||
# prompt rasterization. The runner just builds the config.
|
||||
from framework.skills.config import SkillsConfig
|
||||
from framework.skills.manager import SkillsManagerConfig
|
||||
|
||||
skills_manager_config = SkillsManagerConfig(
|
||||
skills_config=SkillsConfig.from_agent_vars(
|
||||
default_skills=getattr(self, "_agent_default_skills", None),
|
||||
skills=getattr(self, "_agent_skills", None),
|
||||
),
|
||||
project_root=self.agent_path,
|
||||
)
|
||||
|
||||
self._setup_agent_runtime(
|
||||
tools,
|
||||
tool_executor,
|
||||
@@ -1330,6 +1360,7 @@ class AgentRunner:
|
||||
accounts_data=accounts_data,
|
||||
tool_provider_map=tool_provider_map,
|
||||
event_bus=event_bus,
|
||||
skills_manager_config=skills_manager_config,
|
||||
)
|
||||
|
||||
def _get_api_key_env_var(self, model: str) -> str | None:
|
||||
@@ -1364,6 +1395,8 @@ class AgentRunner:
|
||||
return "MINIMAX_API_KEY"
|
||||
elif model_lower.startswith("kimi/"):
|
||||
return "KIMI_API_KEY"
|
||||
elif model_lower.startswith("hive/"):
|
||||
return "HIVE_API_KEY"
|
||||
else:
|
||||
# Default: assume OpenAI-compatible
|
||||
return "OPENAI_API_KEY"
|
||||
@@ -1386,6 +1419,8 @@ class AgentRunner:
|
||||
cred_id = "minimax"
|
||||
elif model_lower.startswith("kimi/"):
|
||||
cred_id = "kimi"
|
||||
elif model_lower.startswith("hive/"):
|
||||
cred_id = "hive"
|
||||
# Add more mappings as providers are added to LLM_CREDENTIALS
|
||||
|
||||
if cred_id is None:
|
||||
@@ -1425,6 +1460,7 @@ class AgentRunner:
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
event_bus=None,
|
||||
skills_manager_config=None,
|
||||
) -> None:
|
||||
"""Set up multi-entry-point execution using AgentRuntime."""
|
||||
entry_points = []
|
||||
@@ -1484,6 +1520,7 @@ class AgentRunner:
|
||||
accounts_data=accounts_data,
|
||||
tool_provider_map=tool_provider_map,
|
||||
event_bus=event_bus,
|
||||
skills_manager_config=skills_manager_config,
|
||||
)
|
||||
|
||||
# Pass intro_message through for TUI display
|
||||
|
||||
@@ -455,11 +455,23 @@ class ToolRegistry:
|
||||
|
||||
for server_config in server_list:
|
||||
server_config = self._resolve_mcp_server_config(server_config, base_dir)
|
||||
try:
|
||||
self.register_mcp_server(server_config)
|
||||
except Exception as e:
|
||||
name = server_config.get("name", "unknown")
|
||||
logger.warning(f"Failed to register MCP server '{name}': {e}")
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
self.register_mcp_server(server_config)
|
||||
break
|
||||
except Exception as e:
|
||||
name = server_config.get("name", "unknown")
|
||||
if _attempt == 0:
|
||||
logger.warning(
|
||||
"MCP server '%s' failed to register, retrying in 2s: %s",
|
||||
name,
|
||||
e,
|
||||
)
|
||||
import time
|
||||
|
||||
time.sleep(2)
|
||||
else:
|
||||
logger.warning("MCP server '%s' failed after retry: %s", name, e)
|
||||
|
||||
# Snapshot credential files and ADEN_API_KEY so we can detect mid-session changes
|
||||
self._mcp_cred_snapshot = self._snapshot_credentials()
|
||||
|
||||
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.goal import Goal
|
||||
from framework.llm.provider import LLMProvider, Tool
|
||||
from framework.skills.manager import SkillsManagerConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -132,6 +133,10 @@ class AgentRuntime:
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
event_bus: "EventBus | None" = None,
|
||||
skills_manager_config: "SkillsManagerConfig | None" = None,
|
||||
# Deprecated — pass skills_manager_config instead.
|
||||
skills_catalog_prompt: str = "",
|
||||
protocols_prompt: str = "",
|
||||
):
|
||||
"""
|
||||
Initialize agent runtime.
|
||||
@@ -153,7 +158,13 @@ class AgentRuntime:
|
||||
event_bus: Optional external EventBus. If provided, the runtime shares
|
||||
this bus instead of creating its own. Used by SessionManager to
|
||||
share a single bus between queen, worker, and judge.
|
||||
skills_manager_config: Skill configuration — the runtime owns
|
||||
discovery, loading, and prompt renderation internally.
|
||||
skills_catalog_prompt: Deprecated. Pre-rendered skills catalog.
|
||||
protocols_prompt: Deprecated. Pre-rendered operational protocols.
|
||||
"""
|
||||
from framework.skills.manager import SkillsManager
|
||||
|
||||
self.graph = graph
|
||||
self.goal = goal
|
||||
self._config = config or AgentRuntimeConfig()
|
||||
@@ -161,6 +172,29 @@ class AgentRuntime:
|
||||
self._checkpoint_config = checkpoint_config
|
||||
self.accounts_prompt = accounts_prompt
|
||||
|
||||
# --- Skill lifecycle: runtime owns the SkillsManager ---
|
||||
if skills_manager_config is not None:
|
||||
# New path: config-driven, runtime handles loading
|
||||
self._skills_manager = SkillsManager(skills_manager_config)
|
||||
self._skills_manager.load()
|
||||
elif skills_catalog_prompt or protocols_prompt:
|
||||
# Legacy path: caller passed pre-rendered strings
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Passing pre-rendered skills_catalog_prompt/protocols_prompt "
|
||||
"is deprecated. Pass skills_manager_config instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._skills_manager = SkillsManager.from_precomputed(
|
||||
skills_catalog_prompt, protocols_prompt
|
||||
)
|
||||
else:
|
||||
# Bare constructor: auto-load defaults
|
||||
self._skills_manager = SkillsManager()
|
||||
self._skills_manager.load()
|
||||
|
||||
# Primary graph identity
|
||||
self._graph_id: str = graph_id or "primary"
|
||||
|
||||
@@ -216,6 +250,18 @@ class AgentRuntime:
|
||||
# Optional greeting shown to user on TUI load (set by AgentRunner)
|
||||
self.intro_message: str = ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Skill prompt accessors (read by ExecutionStream constructors)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def skills_catalog_prompt(self) -> str:
|
||||
return self._skills_manager.skills_catalog_prompt
|
||||
|
||||
@property
|
||||
def protocols_prompt(self) -> str:
|
||||
return self._skills_manager.protocols_prompt
|
||||
|
||||
def register_entry_point(self, spec: EntryPointSpec) -> None:
|
||||
"""
|
||||
Register a named entry point for the agent.
|
||||
@@ -293,6 +339,8 @@ class AgentRuntime:
|
||||
accounts_prompt=self._accounts_prompt,
|
||||
accounts_data=self._accounts_data,
|
||||
tool_provider_map=self._tool_provider_map,
|
||||
skills_catalog_prompt=self.skills_catalog_prompt,
|
||||
protocols_prompt=self.protocols_prompt,
|
||||
)
|
||||
await stream.start()
|
||||
self._streams[ep_id] = stream
|
||||
@@ -393,18 +441,24 @@ class AgentRuntime:
|
||||
|
||||
tc = spec.trigger_config
|
||||
cron_expr = tc.get("cron")
|
||||
interval = tc.get("interval_minutes")
|
||||
_raw_interval = tc.get("interval_minutes")
|
||||
interval = float(_raw_interval) if _raw_interval is not None else None
|
||||
run_immediately = tc.get("run_immediately", False)
|
||||
|
||||
if cron_expr:
|
||||
# Cron expression mode — takes priority over interval_minutes
|
||||
try:
|
||||
from croniter import croniter
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"croniter is required for cron-based entry points. "
|
||||
"Install it with: uv pip install croniter"
|
||||
) from e
|
||||
|
||||
# Validate the expression upfront
|
||||
try:
|
||||
if not croniter.is_valid(cron_expr):
|
||||
raise ValueError(f"Invalid cron expression: {cron_expr}")
|
||||
except (ImportError, ValueError) as e:
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Entry point '%s' has invalid cron config: %s",
|
||||
ep_id,
|
||||
@@ -544,7 +598,7 @@ class AgentRuntime:
|
||||
ep_id,
|
||||
cron_expr,
|
||||
run_immediately,
|
||||
idle_timeout=tc.get("idle_timeout_seconds", 300),
|
||||
idle_timeout=float(tc.get("idle_timeout_seconds", 300)),
|
||||
)()
|
||||
)
|
||||
self._timer_tasks.append(task)
|
||||
@@ -674,7 +728,7 @@ class AgentRuntime:
|
||||
ep_id,
|
||||
interval,
|
||||
run_immediately,
|
||||
idle_timeout=tc.get("idle_timeout_seconds", 300),
|
||||
idle_timeout=float(tc.get("idle_timeout_seconds", 300)),
|
||||
)()
|
||||
)
|
||||
self._timer_tasks.append(task)
|
||||
@@ -921,6 +975,8 @@ class AgentRuntime:
|
||||
accounts_prompt=self._accounts_prompt,
|
||||
accounts_data=self._accounts_data,
|
||||
tool_provider_map=self._tool_provider_map,
|
||||
skills_catalog_prompt=self.skills_catalog_prompt,
|
||||
protocols_prompt=self.protocols_prompt,
|
||||
)
|
||||
if self._running:
|
||||
await stream.start()
|
||||
@@ -999,7 +1055,8 @@ class AgentRuntime:
|
||||
if spec.trigger_type != "timer":
|
||||
continue
|
||||
tc = spec.trigger_config
|
||||
interval = tc.get("interval_minutes")
|
||||
_raw_interval = tc.get("interval_minutes")
|
||||
interval = float(_raw_interval) if _raw_interval is not None else None
|
||||
run_immediately = tc.get("run_immediately", False)
|
||||
|
||||
if interval and interval > 0 and self._running:
|
||||
@@ -1144,7 +1201,7 @@ class AgentRuntime:
|
||||
ep_id,
|
||||
interval,
|
||||
run_immediately,
|
||||
idle_timeout=tc.get("idle_timeout_seconds", 300),
|
||||
idle_timeout=float(tc.get("idle_timeout_seconds", 300)),
|
||||
)()
|
||||
)
|
||||
timer_tasks.append(task)
|
||||
@@ -1699,6 +1756,10 @@ def create_agent_runtime(
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
event_bus: "EventBus | None" = None,
|
||||
skills_manager_config: "SkillsManagerConfig | None" = None,
|
||||
# Deprecated — pass skills_manager_config instead.
|
||||
skills_catalog_prompt: str = "",
|
||||
protocols_prompt: str = "",
|
||||
) -> AgentRuntime:
|
||||
"""
|
||||
Create and configure an AgentRuntime with entry points.
|
||||
@@ -1725,6 +1786,10 @@ def create_agent_runtime(
|
||||
accounts_data: Raw account data for per-node prompt generation.
|
||||
tool_provider_map: Tool name to provider name mapping for account routing.
|
||||
event_bus: Optional external EventBus to share with other components.
|
||||
skills_manager_config: Skill configuration — the runtime owns
|
||||
discovery, loading, and prompt renderation internally.
|
||||
skills_catalog_prompt: Deprecated. Pre-rendered skills catalog.
|
||||
protocols_prompt: Deprecated. Pre-rendered operational protocols.
|
||||
|
||||
Returns:
|
||||
Configured AgentRuntime (not yet started)
|
||||
@@ -1751,6 +1816,9 @@ def create_agent_runtime(
|
||||
accounts_data=accounts_data,
|
||||
tool_provider_map=tool_provider_map,
|
||||
event_bus=event_bus,
|
||||
skills_manager_config=skills_manager_config,
|
||||
skills_catalog_prompt=skills_catalog_prompt,
|
||||
protocols_prompt=protocols_prompt,
|
||||
)
|
||||
|
||||
for spec in entry_points:
|
||||
|
||||
@@ -159,6 +159,7 @@ class EventType(StrEnum):
|
||||
TRIGGER_DEACTIVATED = "trigger_deactivated"
|
||||
TRIGGER_FIRED = "trigger_fired"
|
||||
TRIGGER_REMOVED = "trigger_removed"
|
||||
TRIGGER_UPDATED = "trigger_updated"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -262,7 +263,7 @@ class EventBus:
|
||||
self._session_log: IO[str] | None = None
|
||||
self._session_log_iteration_offset: int = 0
|
||||
# Accumulator for client_output_delta snapshots — flushed on llm_turn_complete.
|
||||
# Key: (stream_id, node_id, execution_id, iteration) → latest AgentEvent
|
||||
# Key: (stream_id, node_id, execution_id, iteration, inner_turn) → latest AgentEvent
|
||||
self._pending_output_snapshots: dict[tuple, AgentEvent] = {}
|
||||
|
||||
def set_session_log(self, path: Path, *, iteration_offset: int = 0) -> None:
|
||||
@@ -328,6 +329,7 @@ class EventBus:
|
||||
event.node_id,
|
||||
event.execution_id,
|
||||
event.data.get("iteration"),
|
||||
event.data.get("inner_turn", 0),
|
||||
)
|
||||
self._pending_output_snapshots[key] = event
|
||||
return
|
||||
@@ -361,7 +363,7 @@ class EventBus:
|
||||
to_flush: list[tuple] = []
|
||||
for key, _evt in self._pending_output_snapshots.items():
|
||||
if stream_id is not None:
|
||||
k_stream, k_node, k_exec, _ = key
|
||||
k_stream, k_node, k_exec, _, _ = key
|
||||
if k_stream != stream_id or k_node != node_id or k_exec != execution_id:
|
||||
continue
|
||||
to_flush.append(key)
|
||||
@@ -749,6 +751,7 @@ class EventBus:
|
||||
content: str,
|
||||
snapshot: str,
|
||||
execution_id: str | None = None,
|
||||
inner_turn: int = 0,
|
||||
) -> None:
|
||||
"""Emit LLM text delta event."""
|
||||
await self.publish(
|
||||
@@ -757,7 +760,7 @@ class EventBus:
|
||||
stream_id=stream_id,
|
||||
node_id=node_id,
|
||||
execution_id=execution_id,
|
||||
data={"content": content, "snapshot": snapshot},
|
||||
data={"content": content, "snapshot": snapshot, "inner_turn": inner_turn},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -873,9 +876,10 @@ class EventBus:
|
||||
snapshot: str,
|
||||
execution_id: str | None = None,
|
||||
iteration: int | None = None,
|
||||
inner_turn: int = 0,
|
||||
) -> None:
|
||||
"""Emit client output delta event (client_facing=True nodes)."""
|
||||
data: dict = {"content": content, "snapshot": snapshot}
|
||||
data: dict = {"content": content, "snapshot": snapshot, "inner_turn": inner_turn}
|
||||
if iteration is not None:
|
||||
data["iteration"] = iteration
|
||||
await self.publish(
|
||||
|
||||
@@ -186,6 +186,8 @@ class ExecutionStream:
|
||||
accounts_prompt: str = "",
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
skills_catalog_prompt: str = "",
|
||||
protocols_prompt: str = "",
|
||||
):
|
||||
"""
|
||||
Initialize execution stream.
|
||||
@@ -209,6 +211,8 @@ class ExecutionStream:
|
||||
accounts_prompt: Connected accounts block for system prompt injection
|
||||
accounts_data: Raw account data for per-node prompt generation
|
||||
tool_provider_map: Tool name to provider name mapping for account routing
|
||||
skills_catalog_prompt: Available skills catalog for system prompt
|
||||
protocols_prompt: Default skill operational protocols for system prompt
|
||||
"""
|
||||
self.stream_id = stream_id
|
||||
self.entry_spec = entry_spec
|
||||
@@ -230,6 +234,21 @@ class ExecutionStream:
|
||||
self._accounts_prompt = accounts_prompt
|
||||
self._accounts_data = accounts_data
|
||||
self._tool_provider_map = tool_provider_map
|
||||
self._skills_catalog_prompt = skills_catalog_prompt
|
||||
self._protocols_prompt = protocols_prompt
|
||||
|
||||
_es_logger = logging.getLogger(__name__)
|
||||
if protocols_prompt:
|
||||
_es_logger.info(
|
||||
"ExecutionStream[%s] received protocols_prompt (%d chars)",
|
||||
stream_id,
|
||||
len(protocols_prompt),
|
||||
)
|
||||
else:
|
||||
_es_logger.warning(
|
||||
"ExecutionStream[%s] received EMPTY protocols_prompt",
|
||||
stream_id,
|
||||
)
|
||||
|
||||
# Create stream-scoped runtime
|
||||
self._runtime = StreamRuntime(
|
||||
@@ -675,6 +694,8 @@ class ExecutionStream:
|
||||
accounts_prompt=self._accounts_prompt,
|
||||
accounts_data=self._accounts_data,
|
||||
tool_provider_map=self._tool_provider_map,
|
||||
skills_catalog_prompt=self._skills_catalog_prompt,
|
||||
protocols_prompt=self._protocols_prompt,
|
||||
)
|
||||
# Track executor so inject_input() can reach EventLoopNode instances
|
||||
self._active_executors[execution_id] = executor
|
||||
|
||||
@@ -47,25 +47,34 @@ class RuntimeLogStore:
|
||||
self._base_path = base_path
|
||||
# Note: _runs_dir is determined per-run_id by _get_run_dir()
|
||||
|
||||
def _session_logs_dir(self, run_id: str) -> Path:
|
||||
"""Return the unified session-backed logs directory for a run ID."""
|
||||
is_runtime_logs = self._base_path.name == "runtime_logs"
|
||||
root = self._base_path.parent if is_runtime_logs else self._base_path
|
||||
return root / "sessions" / run_id / "logs"
|
||||
|
||||
def _legacy_run_dir(self, run_id: str) -> Path:
|
||||
"""Return the deprecated standalone runs directory for a run ID."""
|
||||
return self._base_path / "runs" / run_id
|
||||
|
||||
def _get_run_dir(self, run_id: str) -> Path:
|
||||
"""Determine run directory path based on run_id format.
|
||||
|
||||
- New format (session_*): {storage_root}/sessions/{run_id}/logs/
|
||||
- Session-backed runs: {storage_root}/sessions/{run_id}/logs/
|
||||
- Old format (anything else): {base_path}/runs/{run_id}/ (deprecated)
|
||||
"""
|
||||
if run_id.startswith("session_"):
|
||||
is_runtime_logs = self._base_path.name == "runtime_logs"
|
||||
root = self._base_path.parent if is_runtime_logs else self._base_path
|
||||
return root / "sessions" / run_id / "logs"
|
||||
session_run_dir = self._session_logs_dir(run_id)
|
||||
if session_run_dir.exists() or run_id.startswith("session_"):
|
||||
return session_run_dir
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"Reading logs from deprecated location for run_id={run_id}. "
|
||||
"New sessions use unified storage at sessions/session_*/logs/",
|
||||
"New sessions use unified storage at sessions/<session_id>/logs/",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return self._base_path / "runs" / run_id
|
||||
return self._legacy_run_dir(run_id)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Incremental write (sync — called from locked sections)
|
||||
@@ -76,6 +85,10 @@ class RuntimeLogStore:
|
||||
run_dir = self._get_run_dir(run_id)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def ensure_session_run_dir(self, run_id: str) -> None:
|
||||
"""Create the unified session-backed log directory immediately."""
|
||||
self._session_logs_dir(run_id).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append_step(self, run_id: str, step: NodeStepLog) -> None:
|
||||
"""Append one JSONL line to tool_logs.jsonl. Sync."""
|
||||
path = self._get_run_dir(run_id) / "tool_logs.jsonl"
|
||||
@@ -200,17 +213,17 @@ class RuntimeLogStore:
|
||||
run_ids = []
|
||||
|
||||
# Scan new location: base_path/sessions/{session_id}/logs/
|
||||
# Determine the correct base path for sessions
|
||||
is_runtime_logs = self._base_path.name == "runtime_logs"
|
||||
root = self._base_path.parent if is_runtime_logs else self._base_path
|
||||
sessions_dir = root / "sessions"
|
||||
|
||||
if sessions_dir.exists():
|
||||
for session_dir in sessions_dir.iterdir():
|
||||
if session_dir.is_dir() and session_dir.name.startswith("session_"):
|
||||
logs_dir = session_dir / "logs"
|
||||
if logs_dir.exists() and logs_dir.is_dir():
|
||||
run_ids.append(session_dir.name)
|
||||
if not session_dir.is_dir():
|
||||
continue
|
||||
logs_dir = session_dir / "logs"
|
||||
if logs_dir.exists() and logs_dir.is_dir():
|
||||
run_ids.append(session_dir.name)
|
||||
|
||||
# Scan old location: base_path/runs/ (deprecated)
|
||||
old_runs_dir = self._base_path / "runs"
|
||||
|
||||
@@ -66,15 +66,16 @@ class RuntimeLogger:
|
||||
"""
|
||||
if session_id:
|
||||
self._run_id = session_id
|
||||
self._store.ensure_session_run_dir(self._run_id)
|
||||
else:
|
||||
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
|
||||
short_uuid = uuid.uuid4().hex[:8]
|
||||
self._run_id = f"{ts}_{short_uuid}"
|
||||
self._store.ensure_run_dir(self._run_id)
|
||||
|
||||
self._goal_id = goal_id
|
||||
self._started_at = datetime.now(UTC).isoformat()
|
||||
self._logged_node_ids = set()
|
||||
self._store.ensure_run_dir(self._run_id)
|
||||
return self._run_id
|
||||
|
||||
def log_step(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for custom session-backed runtime logging paths."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from framework.graph.executor import GraphExecutor
|
||||
from framework.runtime.runtime_log_store import RuntimeLogStore
|
||||
from framework.runtime.runtime_logger import RuntimeLogger
|
||||
|
||||
|
||||
def test_graph_executor_uses_custom_session_dir_name_for_runtime_logs():
|
||||
executor = GraphExecutor(
|
||||
runtime=MagicMock(),
|
||||
storage_path=Path("/tmp/test-agent/sessions/my-custom-session"),
|
||||
)
|
||||
|
||||
assert executor._get_runtime_log_session_id() == "my-custom-session"
|
||||
|
||||
|
||||
def test_runtime_logger_creates_session_log_dir_for_custom_session_id(tmp_path):
|
||||
base = tmp_path / ".hive" / "agents" / "test_agent"
|
||||
base.mkdir(parents=True)
|
||||
store = RuntimeLogStore(base)
|
||||
logger = RuntimeLogger(store=store, agent_id="test-agent")
|
||||
|
||||
run_id = logger.start_run(goal_id="goal-1", session_id="my-custom-session")
|
||||
|
||||
assert run_id == "my-custom-session"
|
||||
assert (base / "sessions" / "my-custom-session" / "logs").is_dir()
|
||||
@@ -216,6 +216,16 @@ async def create_queen(
|
||||
+ worker_identity
|
||||
)
|
||||
|
||||
# ---- Default skill protocols -------------------------------------
|
||||
try:
|
||||
from framework.skills.manager import SkillsManager
|
||||
|
||||
_queen_skills_mgr = SkillsManager()
|
||||
_queen_skills_mgr.load()
|
||||
phase_state.protocols_prompt = _queen_skills_mgr.protocols_prompt
|
||||
except Exception:
|
||||
logger.debug("Queen skill loading failed (non-fatal)", exc_info=True)
|
||||
|
||||
# ---- Persona hook ------------------------------------------------
|
||||
_session_llm = session.llm
|
||||
_session_event_bus = session.event_bus
|
||||
|
||||
@@ -103,7 +103,9 @@ async def handle_delete_credential(request: web.Request) -> web.Response:
|
||||
if credential_id == "aden_api_key":
|
||||
from framework.credentials.key_storage import delete_aden_api_key
|
||||
|
||||
delete_aden_api_key()
|
||||
deleted = delete_aden_api_key()
|
||||
if not deleted:
|
||||
return web.json_response({"error": "Credential 'aden_api_key' not found"}, status=404)
|
||||
return web.json_response({"deleted": True})
|
||||
|
||||
store = _get_store(request)
|
||||
@@ -178,7 +180,10 @@ async def handle_check_agent(request: web.Request) -> web.Response:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking agent credentials: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"error": "Internal server error while checking credentials"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
|
||||
def _status_to_dict(c) -> dict:
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
from aiohttp import web
|
||||
from aiohttp.client_exceptions import ClientConnectionResetError as _AiohttpConnReset
|
||||
|
||||
from framework.runtime.event_bus import EventType
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
from framework.server.app import resolve_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,6 +46,7 @@ DEFAULT_EVENT_TYPES = [
|
||||
EventType.TRIGGER_DEACTIVATED,
|
||||
EventType.TRIGGER_FIRED,
|
||||
EventType.TRIGGER_REMOVED,
|
||||
EventType.TRIGGER_UPDATED,
|
||||
EventType.DRAFT_GRAPH_UPDATED,
|
||||
]
|
||||
|
||||
@@ -165,6 +166,54 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
|
||||
if replayed:
|
||||
logger.info("SSE replayed %d buffered events for session='%s'", replayed, session.id)
|
||||
|
||||
# Inject a live-status snapshot so the frontend knows which nodes are
|
||||
# currently running. This covers the case where the user navigated away
|
||||
# and back — the localStorage snapshot is stale, and the ring-buffer
|
||||
# replay may not include the original node_loop_started events.
|
||||
worker_runtime = getattr(session, "worker_runtime", None)
|
||||
if worker_runtime and getattr(worker_runtime, "is_running", False):
|
||||
try:
|
||||
for stream_info in worker_runtime.get_active_streams():
|
||||
graph_id = stream_info.get("graph_id")
|
||||
stream_id = stream_info.get("stream_id", "default")
|
||||
for exec_id in stream_info.get("active_execution_ids", []):
|
||||
# Synthesize execution_started so frontend sets workerRunState
|
||||
synth_exec = AgentEvent(
|
||||
type=EventType.EXECUTION_STARTED,
|
||||
stream_id=stream_id,
|
||||
execution_id=exec_id,
|
||||
graph_id=graph_id,
|
||||
data={"synthetic": True},
|
||||
).to_dict()
|
||||
try:
|
||||
queue.put_nowait(synth_exec)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
# Find the currently executing node via the executor
|
||||
for _gid, reg in worker_runtime._graphs.items():
|
||||
if _gid != graph_id:
|
||||
continue
|
||||
for _ep_id, stream in reg.streams.items():
|
||||
for exec_id, executor in stream._active_executors.items():
|
||||
current = getattr(executor, "current_node_id", None)
|
||||
if current:
|
||||
synth_node = AgentEvent(
|
||||
type=EventType.NODE_LOOP_STARTED,
|
||||
stream_id=stream_id,
|
||||
node_id=current,
|
||||
execution_id=exec_id,
|
||||
graph_id=graph_id,
|
||||
data={"synthetic": True},
|
||||
).to_dict()
|
||||
try:
|
||||
queue.put_nowait(synth_node)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
logger.info("SSE injected live-status snapshot for session='%s'", session.id)
|
||||
except Exception:
|
||||
logger.debug("Failed to inject live-status snapshot", exc_info=True)
|
||||
|
||||
event_count = 0
|
||||
close_reason = "unknown"
|
||||
try:
|
||||
|
||||
@@ -24,6 +24,8 @@ Worker session browsing (persisted execution runs on disk):
|
||||
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
@@ -64,7 +66,9 @@ def _session_to_live_dict(session) -> dict:
|
||||
"loaded_at": session.loaded_at,
|
||||
"uptime_seconds": round(time.time() - session.loaded_at, 1),
|
||||
"intro_message": getattr(session.runner, "intro_message", "") or "",
|
||||
"queen_phase": phase_state.phase if phase_state else "planning",
|
||||
"queen_phase": phase_state.phase
|
||||
if phase_state
|
||||
else ("staging" if session.worker_runtime else "planning"),
|
||||
}
|
||||
|
||||
|
||||
@@ -406,7 +410,7 @@ async def handle_session_entry_points(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def handle_update_trigger_task(request: web.Request) -> web.Response:
|
||||
"""PATCH /api/sessions/{session_id}/triggers/{trigger_id} — update trigger task."""
|
||||
"""PATCH /api/sessions/{session_id}/triggers/{trigger_id} — update trigger fields."""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
@@ -425,30 +429,136 @@ async def handle_update_trigger_task(request: web.Request) -> web.Response:
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON body"}, status=400)
|
||||
|
||||
task = body.get("task")
|
||||
if task is None:
|
||||
return web.json_response({"error": "Missing 'task' field"}, status=400)
|
||||
if not isinstance(task, str):
|
||||
return web.json_response({"error": "'task' must be a string"}, status=400)
|
||||
updates: dict[str, object] = {}
|
||||
|
||||
tdef.task = task
|
||||
if "task" in body:
|
||||
task = body.get("task")
|
||||
if not isinstance(task, str):
|
||||
return web.json_response({"error": "'task' must be a string"}, status=400)
|
||||
tdef.task = task
|
||||
updates["task"] = tdef.task
|
||||
|
||||
trigger_config_update = body.get("trigger_config")
|
||||
if trigger_config_update is not None:
|
||||
if not isinstance(trigger_config_update, dict):
|
||||
return web.json_response(
|
||||
{"error": "'trigger_config' must be an object"},
|
||||
status=400,
|
||||
)
|
||||
merged_trigger_config = dict(tdef.trigger_config)
|
||||
merged_trigger_config.update(trigger_config_update)
|
||||
|
||||
if tdef.trigger_type == "timer":
|
||||
cron_expr = merged_trigger_config.get("cron")
|
||||
interval = merged_trigger_config.get("interval_minutes")
|
||||
if cron_expr is not None and not isinstance(cron_expr, str):
|
||||
return web.json_response(
|
||||
{"error": "'trigger_config.cron' must be a string"},
|
||||
status=400,
|
||||
)
|
||||
if cron_expr:
|
||||
try:
|
||||
from croniter import croniter
|
||||
|
||||
if not croniter.is_valid(cron_expr):
|
||||
return web.json_response(
|
||||
{"error": f"Invalid cron expression: {cron_expr}"},
|
||||
status=400,
|
||||
)
|
||||
except ImportError:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": (
|
||||
"croniter package not installed — cannot validate cron expression."
|
||||
)
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
merged_trigger_config.pop("interval_minutes", None)
|
||||
elif interval is None:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": (
|
||||
"Timer trigger needs 'cron' or 'interval_minutes' in trigger_config."
|
||||
)
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
elif not isinstance(interval, (int, float)) or interval <= 0:
|
||||
return web.json_response(
|
||||
{"error": "'trigger_config.interval_minutes' must be > 0"},
|
||||
status=400,
|
||||
)
|
||||
tdef.trigger_config = merged_trigger_config
|
||||
updates["trigger_config"] = tdef.trigger_config
|
||||
|
||||
if not updates:
|
||||
return web.json_response(
|
||||
{"error": "Provide at least one of 'task' or 'trigger_config'"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Persist to session state and agent definition
|
||||
from framework.tools.queen_lifecycle_tools import (
|
||||
_persist_active_triggers,
|
||||
_save_trigger_to_agent,
|
||||
_start_trigger_timer,
|
||||
_start_trigger_webhook,
|
||||
)
|
||||
|
||||
if "trigger_config" in updates and trigger_id in getattr(session, "active_trigger_ids", set()):
|
||||
task = session.active_timer_tasks.pop(trigger_id, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
getattr(session, "trigger_next_fire", {}).pop(trigger_id, None)
|
||||
|
||||
webhook_subs = getattr(session, "active_webhook_subs", {})
|
||||
if sub_id := webhook_subs.pop(trigger_id, None):
|
||||
with contextlib.suppress(Exception):
|
||||
session.event_bus.unsubscribe(sub_id)
|
||||
|
||||
if tdef.trigger_type == "timer":
|
||||
await _start_trigger_timer(session, trigger_id, tdef)
|
||||
elif tdef.trigger_type == "webhook":
|
||||
await _start_trigger_webhook(session, trigger_id, tdef)
|
||||
|
||||
if trigger_id in getattr(session, "active_trigger_ids", set()):
|
||||
session_id = request.match_info["session_id"]
|
||||
await _persist_active_triggers(session, session_id)
|
||||
|
||||
_save_trigger_to_agent(session, trigger_id, tdef)
|
||||
|
||||
# Emit SSE event so the frontend updates the graph and detail panel
|
||||
bus = getattr(session, "event_bus", None)
|
||||
if bus:
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.TRIGGER_UPDATED,
|
||||
stream_id="queen",
|
||||
data={
|
||||
"trigger_id": trigger_id,
|
||||
"task": tdef.task,
|
||||
"trigger_config": tdef.trigger_config,
|
||||
"trigger_type": tdef.trigger_type,
|
||||
"name": tdef.description or trigger_id,
|
||||
"entry_node": getattr(
|
||||
getattr(getattr(session, "runner", None), "graph", None),
|
||||
"entry_node",
|
||||
None,
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"trigger_id": trigger_id,
|
||||
"task": tdef.task,
|
||||
"trigger_config": tdef.trigger_config,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -492,12 +602,14 @@ async def handle_list_worker_sessions(request: web.Request) -> web.Response:
|
||||
|
||||
sessions = []
|
||||
for d in sorted(sess_dir.iterdir(), reverse=True):
|
||||
if not d.is_dir() or not d.name.startswith("session_"):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
state_path = d / "state.json"
|
||||
if not d.name.startswith("session_") and not state_path.exists():
|
||||
continue
|
||||
|
||||
entry: dict = {"session_id": d.name}
|
||||
|
||||
state_path = d / "state.json"
|
||||
if state_path.exists():
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -868,6 +868,10 @@ class SessionManager:
|
||||
event_type = (
|
||||
EventType.TRIGGER_AVAILABLE if kind == "available" else EventType.TRIGGER_REMOVED
|
||||
)
|
||||
# Resolve graph entry node for trigger target
|
||||
runner = getattr(session, "runner", None)
|
||||
graph_entry = runner.graph.entry_node if runner else None
|
||||
|
||||
for t in triggers.values():
|
||||
await session.event_bus.publish(
|
||||
AgentEvent(
|
||||
@@ -877,6 +881,8 @@ class SessionManager:
|
||||
"trigger_id": t.id,
|
||||
"trigger_type": t.trigger_type,
|
||||
"trigger_config": t.trigger_config,
|
||||
"name": t.description or t.id,
|
||||
**({"entry_node": graph_entry} if graph_entry else {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ Uses aiohttp TestClient with mocked sessions to test all endpoints
|
||||
without requiring actual LLM calls or agent loading.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from framework.runtime.triggers import TriggerDefinition
|
||||
from framework.server.app import create_app
|
||||
from framework.server.session_manager import Session
|
||||
|
||||
@@ -172,6 +174,7 @@ def _make_session(
|
||||
runner.intro_message = "Test intro"
|
||||
|
||||
mock_event_bus = MagicMock()
|
||||
mock_event_bus.publish = AsyncMock()
|
||||
mock_llm = MagicMock()
|
||||
|
||||
queen_executor = _make_queen_executor() if with_queen else None
|
||||
@@ -210,11 +213,8 @@ def tmp_agent_dir(tmp_path, monkeypatch):
|
||||
return tmp_path, agent_name, base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_session(tmp_agent_dir):
|
||||
"""Create a sample session with state.json, checkpoints, and conversations."""
|
||||
tmp_path, agent_name, base = tmp_agent_dir
|
||||
session_id = "session_20260220_120000_abc12345"
|
||||
def _write_sample_session(base: Path, session_id: str):
|
||||
"""Create a sample worker session on disk."""
|
||||
session_dir = base / "sessions" / session_id
|
||||
|
||||
# state.json
|
||||
@@ -295,6 +295,20 @@ def sample_session(tmp_agent_dir):
|
||||
return session_id, session_dir, state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_session(tmp_agent_dir):
|
||||
"""Create a sample session with state.json, checkpoints, and conversations."""
|
||||
_tmp_path, _agent_name, base = tmp_agent_dir
|
||||
return _write_sample_session(base, "session_20260220_120000_abc12345")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def custom_id_session(tmp_agent_dir):
|
||||
"""Create a sample session that uses a custom non-session_* ID."""
|
||||
_tmp_path, _agent_name, base = tmp_agent_dir
|
||||
return _write_sample_session(base, "my-custom-session")
|
||||
|
||||
|
||||
def _make_app_with_session(session):
|
||||
"""Create an aiohttp app with a pre-loaded session."""
|
||||
app = create_app()
|
||||
@@ -473,6 +487,70 @@ class TestSessionCRUD:
|
||||
data = await resp.json()
|
||||
assert "primary" in data["graphs"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_trigger_task(self, tmp_path):
|
||||
session = _make_session(tmp_dir=tmp_path)
|
||||
session.available_triggers["daily"] = TriggerDefinition(
|
||||
id="daily",
|
||||
trigger_type="timer",
|
||||
trigger_config={"cron": "0 5 * * *"},
|
||||
task="Old task",
|
||||
)
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.patch(
|
||||
"/api/sessions/test_agent/triggers/daily",
|
||||
json={"task": "New task"},
|
||||
)
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["task"] == "New task"
|
||||
assert data["trigger_config"]["cron"] == "0 5 * * *"
|
||||
assert session.available_triggers["daily"].task == "New task"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_trigger_cron_restarts_active_timer(self, tmp_path):
|
||||
session = _make_session(tmp_dir=tmp_path)
|
||||
session.available_triggers["daily"] = TriggerDefinition(
|
||||
id="daily",
|
||||
trigger_type="timer",
|
||||
trigger_config={"cron": "0 5 * * *"},
|
||||
task="Run task",
|
||||
active=True,
|
||||
)
|
||||
session.active_trigger_ids.add("daily")
|
||||
session.active_timer_tasks["daily"] = asyncio.create_task(asyncio.sleep(60))
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.patch(
|
||||
"/api/sessions/test_agent/triggers/daily",
|
||||
json={"trigger_config": {"cron": "0 6 * * *"}},
|
||||
)
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["trigger_config"]["cron"] == "0 6 * * *"
|
||||
assert "daily" in session.active_timer_tasks
|
||||
assert session.active_timer_tasks["daily"] is not None
|
||||
assert session.available_triggers["daily"].trigger_config["cron"] == "0 6 * * *"
|
||||
session.active_timer_tasks["daily"].cancel()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_trigger_cron_rejects_invalid_expression(self, tmp_path):
|
||||
session = _make_session(tmp_dir=tmp_path)
|
||||
session.available_triggers["daily"] = TriggerDefinition(
|
||||
id="daily",
|
||||
trigger_type="timer",
|
||||
trigger_config={"cron": "0 5 * * *"},
|
||||
task="Run task",
|
||||
)
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.patch(
|
||||
"/api/sessions/test_agent/triggers/daily",
|
||||
json={"trigger_config": {"cron": "not a cron"}},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
class TestExecution:
|
||||
@pytest.mark.asyncio
|
||||
@@ -799,6 +877,22 @@ class TestWorkerSessions:
|
||||
assert data["sessions"][0]["status"] == "paused"
|
||||
assert data["sessions"][0]["steps"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_includes_custom_id(self, custom_id_session, tmp_agent_dir):
|
||||
session_id, session_dir, state = custom_id_session
|
||||
tmp_path, agent_name, base = tmp_agent_dir
|
||||
|
||||
session = _make_session(tmp_dir=tmp_path / ".hive" / "agents" / agent_name)
|
||||
app = _make_app_with_session(session)
|
||||
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.get("/api/sessions/test_agent/worker-sessions")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert len(data["sessions"]) == 1
|
||||
assert data["sessions"][0]["session_id"] == session_id
|
||||
assert data["sessions"][0]["status"] == "paused"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_empty(self, tmp_agent_dir):
|
||||
tmp_path, agent_name, base = tmp_agent_dir
|
||||
@@ -1316,6 +1410,28 @@ class TestLogs:
|
||||
assert len(data["logs"]) >= 1
|
||||
assert data["logs"][0]["run_id"] == session_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_list_summaries_with_custom_id(self, custom_id_session, tmp_agent_dir):
|
||||
session_id, session_dir, state = custom_id_session
|
||||
tmp_path, agent_name, base = tmp_agent_dir
|
||||
|
||||
from framework.runtime.runtime_log_store import RuntimeLogStore
|
||||
|
||||
log_store = RuntimeLogStore(base)
|
||||
session = _make_session(
|
||||
tmp_dir=tmp_path / ".hive" / "agents" / agent_name,
|
||||
log_store=log_store,
|
||||
)
|
||||
app = _make_app_with_session(session)
|
||||
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.get("/api/sessions/test_agent/logs")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert "logs" in data
|
||||
assert len(data["logs"]) >= 1
|
||||
assert data["logs"][0]["run_id"] == session_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_session_summary(self, sample_session, tmp_agent_dir):
|
||||
session_id, session_dir, state = sample_session
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Hive Agent Skills — discovery, parsing, and injection of SKILL.md packages.
|
||||
|
||||
Implements the open Agent Skills standard (agentskills.io) for portable
|
||||
skill discovery and activation, plus built-in default skills for runtime
|
||||
operational discipline.
|
||||
"""
|
||||
|
||||
from framework.skills.catalog import SkillCatalog
|
||||
from framework.skills.config import DefaultSkillConfig, SkillsConfig
|
||||
from framework.skills.defaults import DefaultSkillManager
|
||||
from framework.skills.discovery import DiscoveryConfig, SkillDiscovery
|
||||
from framework.skills.manager import SkillsManager, SkillsManagerConfig
|
||||
from framework.skills.parser import ParsedSkill, parse_skill_md
|
||||
|
||||
__all__ = [
|
||||
"DefaultSkillConfig",
|
||||
"DefaultSkillManager",
|
||||
"DiscoveryConfig",
|
||||
"ParsedSkill",
|
||||
"SkillCatalog",
|
||||
"SkillDiscovery",
|
||||
"SkillsConfig",
|
||||
"SkillsManager",
|
||||
"SkillsManagerConfig",
|
||||
"parse_skill_md",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: hive.batch-ledger
|
||||
description: Track per-item status when processing collections to prevent skipped or duplicated items.
|
||||
metadata:
|
||||
author: hive
|
||||
type: default-skill
|
||||
---
|
||||
|
||||
## Operational Protocol: Batch Progress Ledger
|
||||
|
||||
When processing a collection of items, maintain a batch ledger in `_batch_ledger`.
|
||||
|
||||
Initialize when you identify the batch:
|
||||
- `_batch_total`: total item count
|
||||
- `_batch_ledger`: JSON with per-item status
|
||||
|
||||
Per-item statuses: pending → in_progress → completed|failed|skipped
|
||||
|
||||
- Set `in_progress` BEFORE processing
|
||||
- Set final status AFTER processing with 1-line result_summary
|
||||
- Include error reason for failed/skipped items
|
||||
- Update aggregate counts after each item
|
||||
- NEVER remove items from the ledger
|
||||
- If resuming, skip items already marked completed
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: hive.context-preservation
|
||||
description: Proactively preserve critical information before automatic context pruning destroys it.
|
||||
metadata:
|
||||
author: hive
|
||||
type: default-skill
|
||||
---
|
||||
|
||||
## Operational Protocol: Context Preservation
|
||||
|
||||
You operate under a finite context window. Important information WILL be pruned.
|
||||
|
||||
Save-As-You-Go: After any tool call producing information you'll need later,
|
||||
immediately extract key data into `_working_notes` or `_preserved_data`.
|
||||
Do NOT rely on referring back to old tool results.
|
||||
|
||||
What to extract: URLs and key snippets (not full pages), relevant API fields
|
||||
(not raw JSON), specific lines/values (not entire files), analysis results
|
||||
(not raw data).
|
||||
|
||||
Before transitioning to the next phase/node, write a handoff summary to
|
||||
`_handoff_context` with everything the next phase needs to know.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: hive.error-recovery
|
||||
description: Follow a structured recovery protocol when tool calls fail instead of blindly retrying or giving up.
|
||||
metadata:
|
||||
author: hive
|
||||
type: default-skill
|
||||
---
|
||||
|
||||
## Operational Protocol: Error Recovery
|
||||
|
||||
When a tool call fails:
|
||||
|
||||
1. Diagnose — record error in notes, classify as transient or structural
|
||||
2. Decide — transient: retry once. Structural fixable: fix and retry.
|
||||
Structural unfixable: record as failed, move to next item.
|
||||
Blocking all progress: record escalation note.
|
||||
3. Adapt — if same tool failed 3+ times, stop using it and find alternative.
|
||||
Update plan in notes. Never silently drop the failed item.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: hive.note-taking
|
||||
description: Maintain structured working notes throughout execution to prevent information loss during context pruning.
|
||||
metadata:
|
||||
author: hive
|
||||
type: default-skill
|
||||
---
|
||||
|
||||
## Operational Protocol: Structured Note-Taking
|
||||
|
||||
Maintain structured working notes in shared memory key `_working_notes`.
|
||||
Update at these checkpoints:
|
||||
|
||||
- After completing each discrete subtask or batch item
|
||||
- After receiving new information that changes your plan
|
||||
- Before any tool call that will produce substantial output
|
||||
|
||||
Structure:
|
||||
|
||||
### Objective — restate the goal
|
||||
### Current Plan — numbered steps, mark completed with ✓
|
||||
### Key Decisions — decisions made and WHY
|
||||
### Working Data — intermediate results, extracted values
|
||||
### Open Questions — uncertainties to verify
|
||||
### Blockers — anything preventing progress
|
||||
|
||||
Update incrementally — do not rewrite from scratch each time.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: hive.quality-monitor
|
||||
description: Periodically self-assess output quality to catch degradation before the judge does.
|
||||
metadata:
|
||||
author: hive
|
||||
type: default-skill
|
||||
---
|
||||
|
||||
## Operational Protocol: Quality Self-Assessment
|
||||
|
||||
Every 5 iterations, self-assess:
|
||||
|
||||
1. On-task? Still working toward the stated objective?
|
||||
2. Thorough? Cutting corners compared to earlier?
|
||||
3. Non-repetitive? Producing new value or rehashing?
|
||||
4. Consistent? Latest output contradict earlier decisions?
|
||||
5. Complete? Tracking all items, or silently dropped some?
|
||||
|
||||
If degrading: write assessment to `_quality_log`, re-read `_working_notes`,
|
||||
change approach explicitly. If acceptable: brief note in `_quality_log`.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: hive.task-decomposition
|
||||
description: Decompose complex tasks into explicit subtasks before diving in.
|
||||
metadata:
|
||||
author: hive
|
||||
type: default-skill
|
||||
---
|
||||
|
||||
## Operational Protocol: Task Decomposition
|
||||
|
||||
Before starting a complex task:
|
||||
|
||||
1. Decompose — break into numbered subtasks in `_working_notes` Current Plan
|
||||
2. Estimate — relative effort per subtask (small/medium/large)
|
||||
3. Execute — work through in order, mark ✓ when complete
|
||||
4. Budget — if running low on iterations, prioritize by impact
|
||||
5. Verify — before declaring done, every subtask must be ✓, skipped (with reason), or blocked
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Skill catalog — in-memory index with system prompt generation.
|
||||
|
||||
Builds the XML catalog injected into the system prompt for model-driven
|
||||
skill activation per the Agent Skills standard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from framework.skills.parser import ParsedSkill
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BEHAVIORAL_INSTRUCTION = (
|
||||
"The following skills provide specialized instructions for specific tasks.\n"
|
||||
"When a task matches a skill's description, read the SKILL.md at the listed\n"
|
||||
"location to load the full instructions before proceeding.\n"
|
||||
"When a skill references relative paths, resolve them against the skill's\n"
|
||||
"directory (the parent of SKILL.md) and use absolute paths in tool calls."
|
||||
)
|
||||
|
||||
|
||||
class SkillCatalog:
|
||||
"""In-memory catalog of discovered skills."""
|
||||
|
||||
def __init__(self, skills: list[ParsedSkill] | None = None):
|
||||
self._skills: dict[str, ParsedSkill] = {}
|
||||
self._activated: set[str] = set()
|
||||
if skills:
|
||||
for skill in skills:
|
||||
self.add(skill)
|
||||
|
||||
def add(self, skill: ParsedSkill) -> None:
|
||||
"""Add a skill to the catalog."""
|
||||
self._skills[skill.name] = skill
|
||||
|
||||
def get(self, name: str) -> ParsedSkill | None:
|
||||
"""Look up a skill by name."""
|
||||
return self._skills.get(name)
|
||||
|
||||
def mark_activated(self, name: str) -> None:
|
||||
"""Mark a skill as activated in the current session."""
|
||||
self._activated.add(name)
|
||||
|
||||
def is_activated(self, name: str) -> bool:
|
||||
"""Check if a skill has been activated."""
|
||||
return name in self._activated
|
||||
|
||||
@property
|
||||
def skill_count(self) -> int:
|
||||
return len(self._skills)
|
||||
|
||||
@property
|
||||
def allowlisted_dirs(self) -> list[str]:
|
||||
"""All skill base directories for file access allowlisting."""
|
||||
return [skill.base_dir for skill in self._skills.values()]
|
||||
|
||||
def to_prompt(self) -> str:
|
||||
"""Generate the catalog prompt for system prompt injection.
|
||||
|
||||
Returns empty string if no community/user skills are discovered
|
||||
(default skills are handled separately by DefaultSkillManager).
|
||||
"""
|
||||
# Filter out framework-scope skills (default skills) — they're
|
||||
# injected via the protocols prompt, not the catalog
|
||||
community_skills = [s for s in self._skills.values() if s.source_scope != "framework"]
|
||||
|
||||
if not community_skills:
|
||||
return ""
|
||||
|
||||
lines = ["<available_skills>"]
|
||||
for skill in sorted(community_skills, key=lambda s: s.name):
|
||||
lines.append(" <skill>")
|
||||
lines.append(f" <name>{escape(skill.name)}</name>")
|
||||
lines.append(f" <description>{escape(skill.description)}</description>")
|
||||
lines.append(f" <location>{escape(skill.location)}</location>")
|
||||
lines.append(" </skill>")
|
||||
lines.append("</available_skills>")
|
||||
|
||||
xml_block = "\n".join(lines)
|
||||
return f"{_BEHAVIORAL_INSTRUCTION}\n\n{xml_block}"
|
||||
|
||||
def build_pre_activated_prompt(self, skill_names: list[str]) -> str:
|
||||
"""Build prompt content for pre-activated skills.
|
||||
|
||||
Pre-activated skills get their full SKILL.md body loaded into
|
||||
the system prompt at startup (tier 2), bypassing model-driven
|
||||
activation.
|
||||
|
||||
Returns empty string if no skills match.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
for name in skill_names:
|
||||
skill = self.get(name)
|
||||
if skill is None:
|
||||
logger.warning("Pre-activated skill '%s' not found in catalog", name)
|
||||
continue
|
||||
if self.is_activated(name):
|
||||
continue # Already activated, skip duplicate
|
||||
|
||||
self.mark_activated(name)
|
||||
parts.append(f"--- Pre-Activated Skill: {skill.name} ---\n{skill.body}")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Skill configuration dataclasses.
|
||||
|
||||
Handles agent-level skill configuration from module-level variables
|
||||
(``default_skills`` and ``skills``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultSkillConfig:
|
||||
"""Configuration for a single default skill."""
|
||||
|
||||
enabled: bool = True
|
||||
overrides: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> DefaultSkillConfig:
|
||||
enabled = data.get("enabled", True)
|
||||
overrides = {k: v for k, v in data.items() if k != "enabled"}
|
||||
return cls(enabled=enabled, overrides=overrides)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillsConfig:
|
||||
"""Agent-level skill configuration.
|
||||
|
||||
Built from module-level variables in agent.py::
|
||||
|
||||
# Pre-activated community skills
|
||||
skills = ["deep-research", "code-review"]
|
||||
|
||||
# Default skill configuration
|
||||
default_skills = {
|
||||
"hive.note-taking": {"enabled": True},
|
||||
"hive.batch-ledger": {"enabled": True, "checkpoint_every_n": 10},
|
||||
"hive.quality-monitor": {"enabled": False},
|
||||
}
|
||||
"""
|
||||
|
||||
# Per-default-skill config, keyed by skill name (e.g. "hive.note-taking")
|
||||
default_skills: dict[str, DefaultSkillConfig] = field(default_factory=dict)
|
||||
|
||||
# Pre-activated community skills (by name)
|
||||
skills: list[str] = field(default_factory=list)
|
||||
|
||||
# Master switch: disable all default skills at once
|
||||
all_defaults_disabled: bool = False
|
||||
|
||||
def is_default_enabled(self, skill_name: str) -> bool:
|
||||
"""Check if a specific default skill is enabled."""
|
||||
if self.all_defaults_disabled:
|
||||
return False
|
||||
config = self.default_skills.get(skill_name)
|
||||
if config is None:
|
||||
return True # enabled by default
|
||||
return config.enabled
|
||||
|
||||
def get_default_overrides(self, skill_name: str) -> dict[str, Any]:
|
||||
"""Get skill-specific configuration overrides."""
|
||||
config = self.default_skills.get(skill_name)
|
||||
if config is None:
|
||||
return {}
|
||||
return config.overrides
|
||||
|
||||
@classmethod
|
||||
def from_agent_vars(
|
||||
cls,
|
||||
default_skills: dict[str, Any] | None = None,
|
||||
skills: list[str] | None = None,
|
||||
) -> SkillsConfig:
|
||||
"""Build config from agent module-level variables.
|
||||
|
||||
Args:
|
||||
default_skills: Dict from agent module, e.g.
|
||||
``{"hive.note-taking": {"enabled": True}}``
|
||||
skills: List of pre-activated skill names from agent module
|
||||
"""
|
||||
all_disabled = False
|
||||
parsed_defaults: dict[str, DefaultSkillConfig] = {}
|
||||
|
||||
if default_skills:
|
||||
for name, config_dict in default_skills.items():
|
||||
if name == "_all":
|
||||
if isinstance(config_dict, dict) and not config_dict.get("enabled", True):
|
||||
all_disabled = True
|
||||
continue
|
||||
if isinstance(config_dict, dict):
|
||||
parsed_defaults[name] = DefaultSkillConfig.from_dict(config_dict)
|
||||
elif isinstance(config_dict, bool):
|
||||
parsed_defaults[name] = DefaultSkillConfig(enabled=config_dict)
|
||||
|
||||
return cls(
|
||||
default_skills=parsed_defaults,
|
||||
skills=list(skills or []),
|
||||
all_defaults_disabled=all_disabled,
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""DefaultSkillManager — load, configure, and inject built-in default skills.
|
||||
|
||||
Default skills are SKILL.md packages shipped with the framework that provide
|
||||
runtime operational protocols (note-taking, batch tracking, error recovery, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from framework.skills.config import SkillsConfig
|
||||
from framework.skills.parser import ParsedSkill, parse_skill_md
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default skills directory relative to this module
|
||||
_DEFAULT_SKILLS_DIR = Path(__file__).parent / "_default_skills"
|
||||
|
||||
# Ordered list of default skills (name → directory)
|
||||
SKILL_REGISTRY: dict[str, str] = {
|
||||
"hive.note-taking": "note-taking",
|
||||
"hive.batch-ledger": "batch-ledger",
|
||||
"hive.context-preservation": "context-preservation",
|
||||
"hive.quality-monitor": "quality-monitor",
|
||||
"hive.error-recovery": "error-recovery",
|
||||
"hive.task-decomposition": "task-decomposition",
|
||||
}
|
||||
|
||||
# All shared memory keys used by default skills (for permission auto-inclusion)
|
||||
SHARED_MEMORY_KEYS: list[str] = [
|
||||
# note-taking
|
||||
"_working_notes",
|
||||
"_notes_updated_at",
|
||||
# batch-ledger
|
||||
"_batch_ledger",
|
||||
"_batch_total",
|
||||
"_batch_completed",
|
||||
"_batch_failed",
|
||||
# context-preservation
|
||||
"_handoff_context",
|
||||
"_preserved_data",
|
||||
# quality-monitor
|
||||
"_quality_log",
|
||||
"_quality_degradation_count",
|
||||
# error-recovery
|
||||
"_error_log",
|
||||
"_failed_tools",
|
||||
"_escalation_needed",
|
||||
# task-decomposition
|
||||
"_subtasks",
|
||||
"_iteration_budget_remaining",
|
||||
]
|
||||
|
||||
|
||||
class DefaultSkillManager:
|
||||
"""Manages loading, configuration, and prompt generation for default skills."""
|
||||
|
||||
def __init__(self, config: SkillsConfig | None = None):
|
||||
self._config = config or SkillsConfig()
|
||||
self._skills: dict[str, ParsedSkill] = {}
|
||||
self._loaded = False
|
||||
|
||||
def load(self) -> None:
|
||||
"""Load all enabled default skill SKILL.md files."""
|
||||
if self._loaded:
|
||||
return
|
||||
|
||||
for skill_name, dir_name in SKILL_REGISTRY.items():
|
||||
if not self._config.is_default_enabled(skill_name):
|
||||
logger.info("Default skill '%s' disabled by config", skill_name)
|
||||
continue
|
||||
|
||||
skill_path = _DEFAULT_SKILLS_DIR / dir_name / "SKILL.md"
|
||||
if not skill_path.is_file():
|
||||
logger.error("Default skill SKILL.md not found: %s", skill_path)
|
||||
continue
|
||||
|
||||
parsed = parse_skill_md(skill_path, source_scope="framework")
|
||||
if parsed is None:
|
||||
logger.error("Failed to parse default skill: %s", skill_path)
|
||||
continue
|
||||
|
||||
self._skills[skill_name] = parsed
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def build_protocols_prompt(self) -> str:
|
||||
"""Build the combined operational protocols section.
|
||||
|
||||
Extracts protocol sections from all enabled default skills and
|
||||
combines them into a single ``## Operational Protocols`` block
|
||||
for system prompt injection.
|
||||
|
||||
Returns empty string if all defaults are disabled.
|
||||
"""
|
||||
if not self._skills:
|
||||
return ""
|
||||
|
||||
parts: list[str] = ["## Operational Protocols\n"]
|
||||
|
||||
for skill_name in SKILL_REGISTRY:
|
||||
skill = self._skills.get(skill_name)
|
||||
if skill is None:
|
||||
continue
|
||||
# Use the full body — each SKILL.md contains exactly one protocol section
|
||||
parts.append(skill.body)
|
||||
|
||||
if len(parts) <= 1:
|
||||
return ""
|
||||
|
||||
combined = "\n\n".join(parts)
|
||||
|
||||
# Token budget warning (approximate: 1 token ≈ 4 chars)
|
||||
approx_tokens = len(combined) // 4
|
||||
if approx_tokens > 2000:
|
||||
logger.warning(
|
||||
"Default skill protocols exceed 2000 token budget "
|
||||
"(~%d tokens, %d chars). Consider trimming.",
|
||||
approx_tokens,
|
||||
len(combined),
|
||||
)
|
||||
|
||||
return combined
|
||||
|
||||
def log_active_skills(self) -> None:
|
||||
"""Log which default skills are active and their configuration."""
|
||||
if not self._skills:
|
||||
logger.info("Default skills: all disabled")
|
||||
return
|
||||
|
||||
active = []
|
||||
for skill_name in SKILL_REGISTRY:
|
||||
if skill_name in self._skills:
|
||||
overrides = self._config.get_default_overrides(skill_name)
|
||||
if overrides:
|
||||
active.append(f"{skill_name} ({overrides})")
|
||||
else:
|
||||
active.append(skill_name)
|
||||
|
||||
logger.info("Default skills active: %s", ", ".join(active))
|
||||
|
||||
@property
|
||||
def active_skill_names(self) -> list[str]:
|
||||
"""Names of all currently active default skills."""
|
||||
return list(self._skills.keys())
|
||||
|
||||
@property
|
||||
def active_skills(self) -> dict[str, ParsedSkill]:
|
||||
"""All active default skills keyed by name."""
|
||||
return dict(self._skills)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Skill discovery — scan standard directories for SKILL.md files.
|
||||
|
||||
Implements the Agent Skills standard discovery paths plus Hive-specific
|
||||
locations. Resolves name collisions deterministically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from framework.skills.parser import ParsedSkill, parse_skill_md
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Directories to skip during scanning
|
||||
_SKIP_DIRS = frozenset(
|
||||
{
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
}
|
||||
)
|
||||
|
||||
# Scope priority (higher = takes precedence)
|
||||
_SCOPE_PRIORITY = {
|
||||
"framework": 0,
|
||||
"user": 1,
|
||||
"project": 2,
|
||||
}
|
||||
|
||||
# Within the same scope, Hive-specific paths override cross-client paths.
|
||||
# We encode this by scanning cross-client first, then Hive-specific (later wins).
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryConfig:
|
||||
"""Configuration for skill discovery."""
|
||||
|
||||
project_root: Path | None = None
|
||||
skip_user_scope: bool = False
|
||||
skip_framework_scope: bool = False
|
||||
max_depth: int = 4
|
||||
max_dirs: int = 2000
|
||||
|
||||
|
||||
class SkillDiscovery:
|
||||
"""Scans standard directories for SKILL.md files and resolves collisions."""
|
||||
|
||||
def __init__(self, config: DiscoveryConfig | None = None):
|
||||
self._config = config or DiscoveryConfig()
|
||||
|
||||
def discover(self) -> list[ParsedSkill]:
|
||||
"""Scan all scopes and return deduplicated skill list.
|
||||
|
||||
Scanning order (lowest to highest precedence):
|
||||
1. Framework defaults
|
||||
2. User cross-client (~/.agents/skills/)
|
||||
3. User Hive-specific (~/.hive/skills/)
|
||||
4. Project cross-client (<project>/.agents/skills/)
|
||||
5. Project Hive-specific (<project>/.hive/skills/)
|
||||
|
||||
Later entries override earlier ones on name collision.
|
||||
"""
|
||||
all_skills: list[ParsedSkill] = []
|
||||
|
||||
# Framework scope (lowest precedence)
|
||||
if not self._config.skip_framework_scope:
|
||||
framework_dir = Path(__file__).parent / "_default_skills"
|
||||
if framework_dir.is_dir():
|
||||
all_skills.extend(self._scan_scope(framework_dir, "framework"))
|
||||
|
||||
# User scope
|
||||
if not self._config.skip_user_scope:
|
||||
home = Path.home()
|
||||
|
||||
# Cross-client (lower precedence within user scope)
|
||||
user_agents = home / ".agents" / "skills"
|
||||
if user_agents.is_dir():
|
||||
all_skills.extend(self._scan_scope(user_agents, "user"))
|
||||
|
||||
# Hive-specific (higher precedence within user scope)
|
||||
user_hive = home / ".hive" / "skills"
|
||||
if user_hive.is_dir():
|
||||
all_skills.extend(self._scan_scope(user_hive, "user"))
|
||||
|
||||
# Project scope (highest precedence)
|
||||
if self._config.project_root:
|
||||
root = self._config.project_root
|
||||
|
||||
# Cross-client
|
||||
project_agents = root / ".agents" / "skills"
|
||||
if project_agents.is_dir():
|
||||
all_skills.extend(self._scan_scope(project_agents, "project"))
|
||||
|
||||
# Hive-specific
|
||||
project_hive = root / ".hive" / "skills"
|
||||
if project_hive.is_dir():
|
||||
all_skills.extend(self._scan_scope(project_hive, "project"))
|
||||
|
||||
resolved = self._resolve_collisions(all_skills)
|
||||
|
||||
logger.info(
|
||||
"Skill discovery: found %d skills (%d after dedup) across all scopes",
|
||||
len(all_skills),
|
||||
len(resolved),
|
||||
)
|
||||
return resolved
|
||||
|
||||
def _scan_scope(self, root: Path, scope: str) -> list[ParsedSkill]:
|
||||
"""Scan a single directory for skill directories containing SKILL.md."""
|
||||
skills: list[ParsedSkill] = []
|
||||
dirs_scanned = 0
|
||||
|
||||
for skill_md in self._find_skill_files(root, depth=0):
|
||||
if dirs_scanned >= self._config.max_dirs:
|
||||
logger.warning(
|
||||
"Hit max directory limit (%d) scanning %s",
|
||||
self._config.max_dirs,
|
||||
root,
|
||||
)
|
||||
break
|
||||
|
||||
parsed = parse_skill_md(skill_md, source_scope=scope)
|
||||
if parsed is not None:
|
||||
skills.append(parsed)
|
||||
dirs_scanned += 1
|
||||
|
||||
return skills
|
||||
|
||||
def _find_skill_files(self, directory: Path, depth: int) -> list[Path]:
|
||||
"""Recursively find SKILL.md files up to max_depth."""
|
||||
if depth > self._config.max_depth:
|
||||
return []
|
||||
|
||||
results: list[Path] = []
|
||||
|
||||
try:
|
||||
entries = sorted(directory.iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
for entry in entries:
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if entry.name in _SKIP_DIRS:
|
||||
continue
|
||||
|
||||
skill_md = entry / "SKILL.md"
|
||||
if skill_md.is_file():
|
||||
results.append(skill_md)
|
||||
else:
|
||||
# Recurse into subdirectories
|
||||
results.extend(self._find_skill_files(entry, depth + 1))
|
||||
|
||||
return results
|
||||
|
||||
def _resolve_collisions(self, skills: list[ParsedSkill]) -> list[ParsedSkill]:
|
||||
"""Resolve name collisions deterministically.
|
||||
|
||||
Later entries in the list override earlier ones (because we scan
|
||||
from lowest to highest precedence). On collision, log a warning.
|
||||
"""
|
||||
seen: dict[str, ParsedSkill] = {}
|
||||
|
||||
for skill in skills:
|
||||
if skill.name in seen:
|
||||
existing = seen[skill.name]
|
||||
logger.warning(
|
||||
"Skill name collision: '%s' from %s overrides %s",
|
||||
skill.name,
|
||||
skill.location,
|
||||
existing.location,
|
||||
)
|
||||
seen[skill.name] = skill
|
||||
|
||||
return list(seen.values())
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Unified skill lifecycle manager.
|
||||
|
||||
``SkillsManager`` is the single facade that owns skill discovery, loading,
|
||||
and prompt renderation. The runtime creates one at startup and downstream
|
||||
layers read the cached prompt strings.
|
||||
|
||||
Typical usage — **config-driven** (runner passes configuration)::
|
||||
|
||||
config = SkillsManagerConfig(
|
||||
skills_config=SkillsConfig.from_agent_vars(...),
|
||||
project_root=agent_path,
|
||||
)
|
||||
mgr = SkillsManager(config)
|
||||
mgr.load()
|
||||
print(mgr.protocols_prompt) # default skill protocols
|
||||
print(mgr.skills_catalog_prompt) # community skills XML
|
||||
|
||||
Typical usage — **bare** (exported agents, SDK users)::
|
||||
|
||||
mgr = SkillsManager() # default config
|
||||
mgr.load() # loads all 6 default skills, no community discovery
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from framework.skills.config import SkillsConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillsManagerConfig:
|
||||
"""Everything the runtime needs to configure skills.
|
||||
|
||||
Attributes:
|
||||
skills_config: Per-skill enable/disable and overrides.
|
||||
project_root: Agent directory for community skill discovery.
|
||||
When ``None``, community discovery is skipped.
|
||||
skip_community_discovery: Explicitly skip community scanning
|
||||
even when ``project_root`` is set.
|
||||
"""
|
||||
|
||||
skills_config: SkillsConfig = field(default_factory=SkillsConfig)
|
||||
project_root: Path | None = None
|
||||
skip_community_discovery: bool = False
|
||||
|
||||
|
||||
class SkillsManager:
|
||||
"""Unified skill lifecycle: discovery → loading → prompt renderation.
|
||||
|
||||
The runtime creates one instance during init and owns it for the
|
||||
lifetime of the process. Downstream layers (``ExecutionStream``,
|
||||
``GraphExecutor``, ``NodeContext``, ``EventLoopNode``) receive the
|
||||
cached prompt strings via property accessors.
|
||||
"""
|
||||
|
||||
def __init__(self, config: SkillsManagerConfig | None = None) -> None:
|
||||
self._config = config or SkillsManagerConfig()
|
||||
self._loaded = False
|
||||
self._catalog_prompt: str = ""
|
||||
self._protocols_prompt: str = ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory for backwards-compat bridge
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_precomputed(
|
||||
cls,
|
||||
skills_catalog_prompt: str = "",
|
||||
protocols_prompt: str = "",
|
||||
) -> SkillsManager:
|
||||
"""Wrap pre-rendered prompt strings (legacy callers).
|
||||
|
||||
Returns a manager that skips discovery/loading and just returns
|
||||
the provided strings. Used by the deprecation bridge in
|
||||
``AgentRuntime`` when callers pass raw prompt strings.
|
||||
"""
|
||||
mgr = cls.__new__(cls)
|
||||
mgr._config = SkillsManagerConfig()
|
||||
mgr._loaded = True # skip load()
|
||||
mgr._catalog_prompt = skills_catalog_prompt
|
||||
mgr._protocols_prompt = protocols_prompt
|
||||
return mgr
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load(self) -> None:
|
||||
"""Discover, load, and cache skill prompts. Idempotent."""
|
||||
if self._loaded:
|
||||
return
|
||||
self._loaded = True
|
||||
|
||||
try:
|
||||
self._do_load()
|
||||
except Exception:
|
||||
logger.warning("Skill system init failed (non-fatal)", exc_info=True)
|
||||
|
||||
def _do_load(self) -> None:
|
||||
"""Internal load — may raise; caller catches."""
|
||||
from framework.skills.catalog import SkillCatalog
|
||||
from framework.skills.defaults import DefaultSkillManager
|
||||
from framework.skills.discovery import DiscoveryConfig, SkillDiscovery
|
||||
|
||||
skills_config = self._config.skills_config
|
||||
|
||||
# 1. Community skill discovery (when project_root is available)
|
||||
catalog_prompt = ""
|
||||
if self._config.project_root is not None and not self._config.skip_community_discovery:
|
||||
discovery = SkillDiscovery(DiscoveryConfig(project_root=self._config.project_root))
|
||||
discovered = discovery.discover()
|
||||
catalog = SkillCatalog(discovered)
|
||||
catalog_prompt = catalog.to_prompt()
|
||||
|
||||
# Pre-activated community skills
|
||||
if skills_config.skills:
|
||||
pre_activated = catalog.build_pre_activated_prompt(skills_config.skills)
|
||||
if pre_activated:
|
||||
if catalog_prompt:
|
||||
catalog_prompt = f"{catalog_prompt}\n\n{pre_activated}"
|
||||
else:
|
||||
catalog_prompt = pre_activated
|
||||
|
||||
# 2. Default skills (always loaded unless explicitly disabled)
|
||||
default_mgr = DefaultSkillManager(config=skills_config)
|
||||
default_mgr.load()
|
||||
default_mgr.log_active_skills()
|
||||
protocols_prompt = default_mgr.build_protocols_prompt()
|
||||
|
||||
# 3. Cache
|
||||
self._catalog_prompt = catalog_prompt
|
||||
self._protocols_prompt = protocols_prompt
|
||||
|
||||
if protocols_prompt:
|
||||
logger.info(
|
||||
"Skill system ready: protocols=%d chars, catalog=%d chars",
|
||||
len(protocols_prompt),
|
||||
len(catalog_prompt),
|
||||
)
|
||||
else:
|
||||
logger.warning("Skill system produced empty protocols_prompt")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Prompt accessors (consumed by downstream layers)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def skills_catalog_prompt(self) -> str:
|
||||
"""Community skills XML catalog for system prompt injection."""
|
||||
return self._catalog_prompt
|
||||
|
||||
@property
|
||||
def protocols_prompt(self) -> str:
|
||||
"""Default skill operational protocols for system prompt injection."""
|
||||
return self._protocols_prompt
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
return self._loaded
|
||||
@@ -0,0 +1,158 @@
|
||||
"""SKILL.md parser — extracts YAML frontmatter and markdown body.
|
||||
|
||||
Parses SKILL.md files per the Agent Skills standard (agentskills.io/specification).
|
||||
Lenient validation: warns on non-critical issues, skips only on missing description
|
||||
or completely unparseable YAML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum name length before a warning is logged
|
||||
_MAX_NAME_LENGTH = 64
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedSkill:
|
||||
"""In-memory representation of a parsed SKILL.md file."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
location: str # absolute path to SKILL.md
|
||||
base_dir: str # parent directory of SKILL.md
|
||||
source_scope: str # "project", "user", or "framework"
|
||||
body: str # markdown body after closing ---
|
||||
|
||||
# Optional frontmatter fields
|
||||
license: str | None = None
|
||||
compatibility: list[str] | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
allowed_tools: list[str] | None = None
|
||||
|
||||
|
||||
def _try_fix_yaml(raw: str) -> str:
|
||||
"""Attempt to fix common YAML issues (unquoted colon values).
|
||||
|
||||
Some SKILL.md files written for other clients may contain unquoted
|
||||
values with colons, e.g. ``description: Use for: research tasks``.
|
||||
This wraps such values in quotes as a best-effort fixup.
|
||||
"""
|
||||
lines = raw.split("\n")
|
||||
fixed = []
|
||||
for line in lines:
|
||||
# Match "key: value" where value contains an unquoted colon
|
||||
m = re.match(r"^(\s*\w[\w-]*:\s*)(.+)$", line)
|
||||
if m:
|
||||
key_part, value_part = m.group(1), m.group(2)
|
||||
# If value contains a colon and isn't already quoted
|
||||
if ":" in value_part and not (value_part.startswith('"') or value_part.startswith("'")):
|
||||
value_part = f'"{value_part}"'
|
||||
fixed.append(f"{key_part}{value_part}")
|
||||
else:
|
||||
fixed.append(line)
|
||||
return "\n".join(fixed)
|
||||
|
||||
|
||||
def parse_skill_md(path: Path, source_scope: str = "project") -> ParsedSkill | None:
|
||||
"""Parse a SKILL.md file into a ParsedSkill record.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the SKILL.md file.
|
||||
source_scope: One of "project", "user", or "framework".
|
||||
|
||||
Returns:
|
||||
ParsedSkill on success, None if the file is unparseable or
|
||||
missing required fields (description).
|
||||
"""
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.error("Failed to read %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
if not content.strip():
|
||||
logger.error("Empty SKILL.md: %s", path)
|
||||
return None
|
||||
|
||||
# Split on --- delimiters (first two occurrences)
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
logger.error("SKILL.md missing YAML frontmatter delimiters (---): %s", path)
|
||||
return None
|
||||
|
||||
# parts[0] is content before first --- (should be empty or whitespace)
|
||||
# parts[1] is the YAML frontmatter
|
||||
# parts[2] is the markdown body
|
||||
raw_yaml = parts[1].strip()
|
||||
body = parts[2].strip()
|
||||
|
||||
if not raw_yaml:
|
||||
logger.error("Empty YAML frontmatter in %s", path)
|
||||
return None
|
||||
|
||||
# Parse YAML
|
||||
import yaml
|
||||
|
||||
frontmatter: dict[str, Any] | None = None
|
||||
try:
|
||||
frontmatter = yaml.safe_load(raw_yaml)
|
||||
except yaml.YAMLError:
|
||||
# Fallback: try fixing unquoted colon values
|
||||
try:
|
||||
fixed = _try_fix_yaml(raw_yaml)
|
||||
frontmatter = yaml.safe_load(fixed)
|
||||
logger.warning("Fixed YAML parse issues in %s (unquoted colons)", path)
|
||||
except yaml.YAMLError as exc:
|
||||
logger.error("Unparseable YAML in %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
logger.error("YAML frontmatter is not a mapping in %s", path)
|
||||
return None
|
||||
|
||||
# Required: description
|
||||
description = frontmatter.get("description")
|
||||
if not description or not str(description).strip():
|
||||
logger.error("Missing or empty 'description' in %s — skipping skill", path)
|
||||
return None
|
||||
|
||||
# Required: name (fallback to parent directory name)
|
||||
name = frontmatter.get("name")
|
||||
parent_dir_name = path.parent.name
|
||||
if not name or not str(name).strip():
|
||||
name = parent_dir_name
|
||||
logger.warning("Missing 'name' in %s — using directory name '%s'", path, name)
|
||||
else:
|
||||
name = str(name).strip()
|
||||
|
||||
# Lenient warnings
|
||||
if len(name) > _MAX_NAME_LENGTH:
|
||||
logger.warning("Skill name exceeds %d chars in %s: '%s'", _MAX_NAME_LENGTH, path, name)
|
||||
|
||||
if name != parent_dir_name and not name.endswith(f".{parent_dir_name}"):
|
||||
logger.warning(
|
||||
"Skill name '%s' doesn't match parent directory '%s' in %s",
|
||||
name,
|
||||
parent_dir_name,
|
||||
path,
|
||||
)
|
||||
|
||||
return ParsedSkill(
|
||||
name=name,
|
||||
description=str(description).strip(),
|
||||
location=str(path.resolve()),
|
||||
base_dir=str(path.parent.resolve()),
|
||||
source_scope=source_scope,
|
||||
body=body,
|
||||
license=frontmatter.get("license"),
|
||||
compatibility=frontmatter.get("compatibility"),
|
||||
metadata=frontmatter.get("metadata"),
|
||||
allowed_tools=frontmatter.get("allowed-tools"),
|
||||
)
|
||||
@@ -40,18 +40,31 @@ class LLMJudge:
|
||||
|
||||
def _get_fallback_provider(self) -> LLMProvider | None:
|
||||
"""
|
||||
Auto-detects available API keys and returns the appropriate provider.
|
||||
Priority: OpenAI -> Anthropic.
|
||||
Auto-detects available API keys and returns an appropriate provider.
|
||||
Uses LiteLLM for OpenAI (framework has no framework.llm.openai module).
|
||||
Priority:
|
||||
1. OpenAI-compatible models via LiteLLM (OPENAI_API_KEY)
|
||||
2. Anthropic via AnthropicProvider (ANTHROPIC_API_KEY)
|
||||
"""
|
||||
# OpenAI: use LiteLLM (the framework's standard multi-provider integration)
|
||||
if os.environ.get("OPENAI_API_KEY"):
|
||||
from framework.llm.openai import OpenAIProvider
|
||||
try:
|
||||
from framework.llm.litellm import LiteLLMProvider
|
||||
|
||||
return OpenAIProvider(model="gpt-4o-mini")
|
||||
return LiteLLMProvider(model="gpt-4o-mini")
|
||||
except ImportError:
|
||||
# LiteLLM is optional; fall through to Anthropic/None
|
||||
pass
|
||||
|
||||
# Anthropic via dedicated provider (wraps LiteLLM internally)
|
||||
if os.environ.get("ANTHROPIC_API_KEY"):
|
||||
from framework.llm.anthropic import AnthropicProvider
|
||||
try:
|
||||
from framework.llm.anthropic import AnthropicProvider
|
||||
|
||||
return AnthropicProvider(model="claude-3-haiku-20240307")
|
||||
return AnthropicProvider(model="claude-haiku-4-5-20251001")
|
||||
except Exception:
|
||||
# If AnthropicProvider cannot be constructed, treat as no fallback
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@@ -77,11 +90,16 @@ SUMMARY TO EVALUATE:
|
||||
Respond with JSON: {{"passes": true/false, "explanation": "..."}}"""
|
||||
|
||||
try:
|
||||
# Compute fallback provider once so we do not create multiple instances
|
||||
fallback_provider = self._get_fallback_provider()
|
||||
|
||||
# 1. Use injected provider
|
||||
if self._provider:
|
||||
active_provider = self._provider
|
||||
# 2. Check if _get_client was MOCKED (legacy tests) or use Agnostic Fallback
|
||||
elif hasattr(self._get_client, "return_value") or not self._get_fallback_provider():
|
||||
# 2. Legacy path: anthropic client mocked in tests takes precedence,
|
||||
# or no fallback provider is available.
|
||||
elif hasattr(self._get_client, "return_value") or fallback_provider is None:
|
||||
# Use legacy Anthropic client (e.g. when tests mock _get_client, or no env keys set)
|
||||
client = self._get_client()
|
||||
response = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
@@ -90,7 +108,8 @@ Respond with JSON: {{"passes": true/false, "explanation": "..."}}"""
|
||||
)
|
||||
return self._parse_json_result(response.content[0].text.strip())
|
||||
else:
|
||||
active_provider = self._get_fallback_provider()
|
||||
# Use env-based fallback (LiteLLM or AnthropicProvider)
|
||||
active_provider = fallback_provider
|
||||
|
||||
response = active_provider.complete(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Flowchart utilities for generating and persisting flowchart.json files.
|
||||
|
||||
Extracted from queen_lifecycle_tools so that non-Queen code paths
|
||||
(e.g., AgentRunner.load) can generate flowcharts for legacy agents
|
||||
that lack a flowchart.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FLOWCHART_FILENAME = "flowchart.json"
|
||||
|
||||
# ── Flowchart type catalogue (9 types) ───────────────────────────────────────
|
||||
FLOWCHART_TYPES = {
|
||||
"start": {"shape": "stadium", "color": "#8aad3f"}, # spring pollen
|
||||
"terminal": {"shape": "stadium", "color": "#b5453a"}, # propolis red
|
||||
"process": {"shape": "rectangle", "color": "#b5a575"}, # warm wheat
|
||||
"decision": {"shape": "diamond", "color": "#d89d26"}, # royal honey
|
||||
"io": {"shape": "parallelogram", "color": "#d06818"}, # burnt orange
|
||||
"document": {"shape": "document", "color": "#c4b830"}, # goldenrod
|
||||
"database": {"shape": "cylinder", "color": "#508878"}, # sage teal
|
||||
"subprocess": {"shape": "subroutine", "color": "#887a48"}, # propolis gold
|
||||
"browser": {"shape": "hexagon", "color": "#cc8850"}, # honey copper
|
||||
}
|
||||
|
||||
# Backward-compat remap: old type names → canonical type
|
||||
FLOWCHART_REMAP: dict[str, str] = {
|
||||
"delay": "process",
|
||||
"manual_operation": "process",
|
||||
"preparation": "process",
|
||||
"merge": "process",
|
||||
"alternate_process": "process",
|
||||
"connector": "process",
|
||||
"offpage_connector": "process",
|
||||
"extract": "process",
|
||||
"sort": "process",
|
||||
"collate": "process",
|
||||
"summing_junction": "process",
|
||||
"or": "process",
|
||||
"comment": "process",
|
||||
"display": "io",
|
||||
"manual_input": "io",
|
||||
"multi_document": "document",
|
||||
"stored_data": "database",
|
||||
"internal_storage": "database",
|
||||
}
|
||||
|
||||
|
||||
# ── File persistence ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_flowchart_file(
|
||||
agent_path: Path | str | None,
|
||||
original_draft: dict,
|
||||
flowchart_map: dict[str, list[str]] | None,
|
||||
) -> None:
|
||||
"""Persist the flowchart to the agent's folder."""
|
||||
if agent_path is None:
|
||||
return
|
||||
p = Path(agent_path)
|
||||
if not p.is_dir():
|
||||
return
|
||||
try:
|
||||
target = p / FLOWCHART_FILENAME
|
||||
target.write_text(
|
||||
json.dumps(
|
||||
{"original_draft": original_draft, "flowchart_map": flowchart_map},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.debug("Flowchart saved to %s", target)
|
||||
except Exception:
|
||||
logger.warning("Failed to save flowchart to %s", p, exc_info=True)
|
||||
|
||||
|
||||
def load_flowchart_file(
|
||||
agent_path: Path | str | None,
|
||||
) -> tuple[dict | None, dict[str, list[str]] | None]:
|
||||
"""Load flowchart from the agent's folder. Returns (original_draft, flowchart_map)."""
|
||||
if agent_path is None:
|
||||
return None, None
|
||||
target = Path(agent_path) / FLOWCHART_FILENAME
|
||||
if not target.is_file():
|
||||
return None, None
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
return data.get("original_draft"), data.get("flowchart_map")
|
||||
except Exception:
|
||||
logger.warning("Failed to load flowchart from %s", target, exc_info=True)
|
||||
return None, None
|
||||
|
||||
|
||||
# ── Node classification ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def classify_flowchart_node(
|
||||
node: dict,
|
||||
index: int,
|
||||
total: int,
|
||||
edges: list[dict],
|
||||
terminal_ids: set[str],
|
||||
) -> str:
|
||||
"""Auto-detect the ISO 5807 flowchart type for a draft node.
|
||||
|
||||
Priority: explicit override > structural detection > heuristic > default.
|
||||
"""
|
||||
# Explicit override from the queen
|
||||
explicit = node.get("flowchart_type", "").strip()
|
||||
if explicit and explicit in FLOWCHART_TYPES:
|
||||
return explicit
|
||||
if explicit and explicit in FLOWCHART_REMAP:
|
||||
return FLOWCHART_REMAP[explicit]
|
||||
|
||||
node_id = node["id"]
|
||||
node_type = node.get("node_type", "event_loop")
|
||||
node_tools = set(node.get("tools") or [])
|
||||
desc = (node.get("description") or "").lower()
|
||||
|
||||
# GCU / browser automation nodes → hexagon
|
||||
if node_type == "gcu":
|
||||
return "browser"
|
||||
|
||||
# Entry node (first node or no incoming edges) → start terminator
|
||||
incoming = {e["target"] for e in edges}
|
||||
if index == 0 or (node_id not in incoming and index == 0):
|
||||
return "start"
|
||||
|
||||
# Terminal node → end terminator
|
||||
if node_id in terminal_ids:
|
||||
return "terminal"
|
||||
|
||||
# Decision node: has outgoing edges with branching conditions → diamond
|
||||
outgoing = [e for e in edges if e["source"] == node_id]
|
||||
if len(outgoing) >= 2:
|
||||
conditions = {e.get("condition", "on_success") for e in outgoing}
|
||||
if len(conditions) > 1 or conditions - {"on_success"}:
|
||||
return "decision"
|
||||
|
||||
# Sub-agent / subprocess nodes → subroutine (double-bordered rect)
|
||||
if node.get("sub_agents"):
|
||||
return "subprocess"
|
||||
|
||||
# Database / data store nodes → cylinder
|
||||
db_tool_hints = {
|
||||
"query_database",
|
||||
"sql_query",
|
||||
"read_table",
|
||||
"write_table",
|
||||
"save_data",
|
||||
"load_data",
|
||||
}
|
||||
db_desc_hints = {"database", "data store", "storage", "persist", "cache"}
|
||||
if node_tools & db_tool_hints or any(h in desc for h in db_desc_hints):
|
||||
return "database"
|
||||
|
||||
# Document generation nodes → document shape
|
||||
doc_tool_hints = {
|
||||
"generate_report",
|
||||
"create_document",
|
||||
"write_report",
|
||||
"render_template",
|
||||
"export_pdf",
|
||||
}
|
||||
doc_desc_hints = {"report", "document", "summary", "write up", "writeup"}
|
||||
if node_tools & doc_tool_hints or any(h in desc for h in doc_desc_hints):
|
||||
return "document"
|
||||
|
||||
# I/O nodes: external data ingestion or delivery → parallelogram
|
||||
io_tool_hints = {
|
||||
"serve_file_to_user",
|
||||
"send_email",
|
||||
"post_message",
|
||||
"upload_file",
|
||||
"download_file",
|
||||
"fetch_url",
|
||||
"post_to_slack",
|
||||
"send_notification",
|
||||
"display_results",
|
||||
}
|
||||
io_desc_hints = {"deliver", "send", "output", "notify", "publish"}
|
||||
if node_tools & io_tool_hints or any(h in desc for h in io_desc_hints):
|
||||
return "io"
|
||||
|
||||
# Default: process (rectangle)
|
||||
return "process"
|
||||
|
||||
|
||||
# ── Draft synthesis from runtime graph ───────────────────────────────────────
|
||||
|
||||
|
||||
def synthesize_draft_from_runtime(
|
||||
runtime_nodes: list,
|
||||
runtime_edges: list,
|
||||
agent_name: str = "",
|
||||
goal_name: str = "",
|
||||
) -> tuple[dict, dict[str, list[str]]]:
|
||||
"""Generate a flowchart draft from a loaded runtime graph.
|
||||
|
||||
Used for agents that were never planned through the draft workflow
|
||||
(e.g., hand-coded or loaded from "my agents"). Produces a valid
|
||||
DraftGraph structure with auto-classified flowchart types.
|
||||
"""
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
node_ids = {n.id for n in runtime_nodes}
|
||||
|
||||
# Build edge dicts first (needed for classification)
|
||||
for i, re in enumerate(runtime_edges):
|
||||
edges.append(
|
||||
{
|
||||
"id": f"edge-{i}",
|
||||
"source": re.source,
|
||||
"target": re.target,
|
||||
"condition": str(re.condition.value)
|
||||
if hasattr(re.condition, "value")
|
||||
else str(re.condition),
|
||||
"description": getattr(re, "description", "") or "",
|
||||
"label": "",
|
||||
}
|
||||
)
|
||||
|
||||
# Terminal detection — exclude sub-agent nodes (they are leaf helpers, not endpoints)
|
||||
sub_agent_ids: set[str] = set()
|
||||
for rn in runtime_nodes:
|
||||
for sa_id in getattr(rn, "sub_agents", None) or []:
|
||||
sub_agent_ids.add(sa_id)
|
||||
sources = {e["source"] for e in edges}
|
||||
terminal_ids = node_ids - sources - sub_agent_ids
|
||||
if not terminal_ids and runtime_nodes:
|
||||
terminal_ids = {runtime_nodes[-1].id}
|
||||
|
||||
# Build node dicts with classification
|
||||
total = len(runtime_nodes)
|
||||
for i, rn in enumerate(runtime_nodes):
|
||||
node: dict = {
|
||||
"id": rn.id,
|
||||
"name": rn.name,
|
||||
"description": rn.description or "",
|
||||
"node_type": getattr(rn, "node_type", "event_loop") or "event_loop",
|
||||
"tools": list(rn.tools) if rn.tools else [],
|
||||
"input_keys": list(rn.input_keys) if rn.input_keys else [],
|
||||
"output_keys": list(rn.output_keys) if rn.output_keys else [],
|
||||
"success_criteria": getattr(rn, "success_criteria", "") or "",
|
||||
"sub_agents": list(rn.sub_agents) if getattr(rn, "sub_agents", None) else [],
|
||||
}
|
||||
fc_type = classify_flowchart_node(node, i, total, edges, terminal_ids)
|
||||
fc_meta = FLOWCHART_TYPES[fc_type]
|
||||
node["flowchart_type"] = fc_type
|
||||
node["flowchart_shape"] = fc_meta["shape"]
|
||||
node["flowchart_color"] = fc_meta["color"]
|
||||
nodes.append(node)
|
||||
|
||||
# Add visual edges from parent nodes to their sub_agents.
|
||||
# Sub-agents are connected via the sub_agents field, not via EdgeSpec,
|
||||
# so they'd appear as disconnected islands without this.
|
||||
# Two edges per sub-agent: delegate (parent→sub) and report (sub→parent).
|
||||
edge_counter = len(edges)
|
||||
for node in nodes:
|
||||
for sa_id in node.get("sub_agents") or []:
|
||||
if sa_id in node_ids:
|
||||
edges.append(
|
||||
{
|
||||
"id": f"edge-subagent-{edge_counter}",
|
||||
"source": node["id"],
|
||||
"target": sa_id,
|
||||
"condition": "always",
|
||||
"description": "sub-agent delegation",
|
||||
"label": "delegate",
|
||||
}
|
||||
)
|
||||
edge_counter += 1
|
||||
edges.append(
|
||||
{
|
||||
"id": f"edge-subagent-{edge_counter}",
|
||||
"source": sa_id,
|
||||
"target": node["id"],
|
||||
"condition": "always",
|
||||
"description": "sub-agent report back",
|
||||
"label": "report",
|
||||
}
|
||||
)
|
||||
edge_counter += 1
|
||||
|
||||
# Group sub-agent nodes under their parent in the flowchart map
|
||||
# (mirrors what _dissolve_planning_nodes does for planned drafts)
|
||||
sub_agent_ids_final: set[str] = set()
|
||||
for node in nodes:
|
||||
for sa_id in node.get("sub_agents") or []:
|
||||
if sa_id in node_ids:
|
||||
sub_agent_ids_final.add(sa_id)
|
||||
|
||||
fmap: dict[str, list[str]] = {}
|
||||
for node in nodes:
|
||||
nid = node["id"]
|
||||
if nid in sub_agent_ids_final:
|
||||
continue # skip — will be included via parent
|
||||
absorbed = [nid]
|
||||
for sa_id in node.get("sub_agents") or []:
|
||||
if sa_id in node_ids:
|
||||
absorbed.append(sa_id)
|
||||
fmap[nid] = absorbed
|
||||
|
||||
draft = {
|
||||
"agent_name": agent_name,
|
||||
"goal": goal_name,
|
||||
"description": "",
|
||||
"success_criteria": [],
|
||||
"constraints": [],
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"entry_node": nodes[0]["id"] if nodes else "",
|
||||
"terminal_nodes": sorted(terminal_ids),
|
||||
"flowchart_legend": {
|
||||
fc_type: {"shape": meta["shape"], "color": meta["color"]}
|
||||
for fc_type, meta in FLOWCHART_TYPES.items()
|
||||
},
|
||||
}
|
||||
|
||||
return draft, fmap
|
||||
|
||||
|
||||
# ── Fallback generation entry point ──────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_fallback_flowchart(
|
||||
graph: Any,
|
||||
goal: Any,
|
||||
agent_path: Path,
|
||||
) -> None:
|
||||
"""Generate flowchart.json from a runtime GraphSpec if none exists.
|
||||
|
||||
This is a no-op if flowchart.json already exists. On failure, logs a
|
||||
warning but never raises — agent loading must not be blocked by
|
||||
flowchart generation.
|
||||
"""
|
||||
try:
|
||||
existing_draft, _ = load_flowchart_file(agent_path)
|
||||
if existing_draft is not None:
|
||||
return # already have one
|
||||
|
||||
draft, fmap = synthesize_draft_from_runtime(
|
||||
runtime_nodes=list(graph.nodes),
|
||||
runtime_edges=list(graph.edges),
|
||||
agent_name=agent_path.name,
|
||||
goal_name=goal.name if goal else "",
|
||||
)
|
||||
|
||||
# Enrich with Goal metadata
|
||||
if goal:
|
||||
draft["goal"] = goal.description or goal.name or ""
|
||||
draft["success_criteria"] = [sc.description for sc in (goal.success_criteria or [])]
|
||||
draft["constraints"] = [c.description for c in (goal.constraints or [])]
|
||||
|
||||
# Use entry_node/terminal_nodes from GraphSpec if available
|
||||
if graph.entry_node:
|
||||
draft["entry_node"] = graph.entry_node
|
||||
if graph.terminal_nodes:
|
||||
draft["terminal_nodes"] = list(graph.terminal_nodes)
|
||||
|
||||
save_flowchart_file(agent_path, draft, fmap)
|
||||
logger.info("Generated fallback flowchart.json for %s", agent_path.name)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to generate fallback flowchart for %s",
|
||||
agent_path,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -36,6 +36,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -45,6 +46,13 @@ from framework.credentials.models import CredentialError
|
||||
from framework.runner.preload_validation import credential_errors_to_json, validate_credentials
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
from framework.server.app import validate_agent_path
|
||||
from framework.tools.flowchart_utils import (
|
||||
FLOWCHART_TYPES,
|
||||
classify_flowchart_node,
|
||||
load_flowchart_file,
|
||||
save_flowchart_file,
|
||||
synthesize_draft_from_runtime,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
@@ -108,6 +116,9 @@ class QueenPhaseState:
|
||||
prompt_staging: str = ""
|
||||
prompt_running: str = ""
|
||||
|
||||
# Default skill operational protocols — appended to every phase prompt
|
||||
protocols_prompt: str = ""
|
||||
|
||||
def get_current_tools(self) -> list:
|
||||
"""Return tools for the current phase."""
|
||||
if self.phase == "planning":
|
||||
@@ -132,7 +143,12 @@ class QueenPhaseState:
|
||||
from framework.agents.queen.queen_memory import format_for_injection
|
||||
|
||||
memory = format_for_injection()
|
||||
return base + ("\n\n" + memory if memory else "")
|
||||
parts = [base]
|
||||
if self.protocols_prompt:
|
||||
parts.append(self.protocols_prompt)
|
||||
if memory:
|
||||
parts.append(memory)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
async def _emit_phase_event(self) -> None:
|
||||
"""Publish a QUEEN_PHASE_CHANGED event so the frontend updates the tag."""
|
||||
@@ -285,66 +301,7 @@ def build_worker_profile(runtime: AgentRuntime, agent_path: Path | str | None =
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_FLOWCHART_TYPES = {
|
||||
# ── Core symbols (ISO 5807 §4) ──────────────────────────
|
||||
# Terminator — rounded rectangle (stadium shape)
|
||||
"start": {"shape": "stadium", "color": "#4CAF50"}, # green
|
||||
"terminal": {"shape": "stadium", "color": "#F44336"}, # red
|
||||
# Process — rectangle
|
||||
"process": {"shape": "rectangle", "color": "#2196F3"}, # blue
|
||||
# Decision — diamond
|
||||
"decision": {"shape": "diamond", "color": "#FF9800"}, # amber
|
||||
# Data (Input/Output) — parallelogram
|
||||
"io": {"shape": "parallelogram", "color": "#9C27B0"}, # purple
|
||||
# Document — rectangle with wavy bottom
|
||||
"document": {"shape": "document", "color": "#607D8B"}, # blue-grey
|
||||
# Multi-document — stacked documents
|
||||
"multi_document": {"shape": "multi_document", "color": "#78909C"}, # blue-grey light
|
||||
# Predefined process / subroutine — rectangle with double vertical bars
|
||||
"subprocess": {"shape": "subroutine", "color": "#009688"}, # teal
|
||||
# Preparation — hexagon
|
||||
"preparation": {"shape": "hexagon", "color": "#795548"}, # brown
|
||||
# Manual input — trapezoid with slanted top
|
||||
"manual_input": {"shape": "manual_input", "color": "#E91E63"}, # pink
|
||||
# Manual operation — inverted trapezoid
|
||||
"manual_operation": {"shape": "trapezoid", "color": "#AD1457"}, # dark pink
|
||||
# Delay — half-rounded rectangle (D-shape)
|
||||
"delay": {"shape": "delay", "color": "#FF5722"}, # deep orange
|
||||
# Display — rounded rectangle with pointed left
|
||||
"display": {"shape": "display", "color": "#00BCD4"}, # cyan
|
||||
# ── Data storage symbols ────────────────────────────────
|
||||
# Database / direct access storage — cylinder
|
||||
"database": {"shape": "cylinder", "color": "#8BC34A"}, # light green
|
||||
# Stored data — generic data store
|
||||
"stored_data": {"shape": "stored_data", "color": "#CDDC39"}, # lime
|
||||
# Internal storage — rectangle with cross-hatch
|
||||
"internal_storage": {"shape": "internal_storage", "color": "#FFC107"}, # amber light
|
||||
# ── Connectors ──────────────────────────────────────────
|
||||
# On-page connector — small circle
|
||||
"connector": {"shape": "circle", "color": "#9E9E9E"}, # grey
|
||||
# Off-page connector — pentagon / home-plate
|
||||
"offpage_connector": {"shape": "pentagon", "color": "#757575"}, # dark grey
|
||||
# ── Flow operations ─────────────────────────────────────
|
||||
# Merge — inverted triangle
|
||||
"merge": {"shape": "triangle_inv", "color": "#3F51B5"}, # indigo
|
||||
# Extract — upward triangle
|
||||
"extract": {"shape": "triangle", "color": "#5C6BC0"}, # indigo light
|
||||
# Sort — hourglass / double triangle
|
||||
"sort": {"shape": "hourglass", "color": "#7986CB"}, # indigo lighter
|
||||
# Collate — merged hourglass
|
||||
"collate": {"shape": "hourglass_inv", "color": "#9FA8DA"}, # indigo lightest
|
||||
# Summing junction — circle with cross
|
||||
"summing_junction": {"shape": "circle_cross", "color": "#F06292"}, # pink light
|
||||
# Or — circle with horizontal bar
|
||||
"or": {"shape": "circle_bar", "color": "#CE93D8"}, # purple light
|
||||
# ── Domain-specific (Hive agent context) ────────────────
|
||||
# Browser automation (GCU) — mapped to preparation/hexagon
|
||||
"browser": {"shape": "hexagon", "color": "#1A237E"}, # dark indigo
|
||||
# Comment / annotation — flag shape
|
||||
"comment": {"shape": "flag", "color": "#BDBDBD"}, # light grey
|
||||
# Alternate process — rounded rectangle
|
||||
"alternate_process": {"shape": "rounded_rect", "color": "#42A5F5"}, # light blue
|
||||
}
|
||||
# FLOWCHART_TYPES is imported from framework.tools.flowchart_utils
|
||||
|
||||
|
||||
def _read_agent_triggers_json(agent_path: Path) -> list[dict]:
|
||||
@@ -451,10 +408,11 @@ async def _start_trigger_timer(session: Any, trigger_id: str, tdef: Any) -> None
|
||||
else:
|
||||
await asyncio.sleep(float(interval_minutes) * 60)
|
||||
|
||||
# Record next fire time for introspection
|
||||
# Record next fire time for introspection (monotonic, matches routes)
|
||||
fire_times = getattr(session, "trigger_next_fire", None)
|
||||
if fire_times is not None:
|
||||
fire_times[trigger_id] = datetime.now(tz=UTC).isoformat()
|
||||
_next_delay = float(interval_minutes) * 60 if interval_minutes else 60
|
||||
fire_times[trigger_id] = time.monotonic() + _next_delay
|
||||
|
||||
# Gate on worker being loaded
|
||||
if getattr(session, "worker_runtime", None) is None:
|
||||
@@ -635,7 +593,7 @@ def _dissolve_planning_nodes(
|
||||
if not predecessors:
|
||||
# Decision at start: convert to regular process node
|
||||
d_node["flowchart_type"] = "process"
|
||||
fc_meta = _FLOWCHART_TYPES["process"]
|
||||
fc_meta = FLOWCHART_TYPES["process"]
|
||||
d_node["flowchart_shape"] = fc_meta["shape"]
|
||||
d_node["flowchart_color"] = fc_meta["color"]
|
||||
if not d_node.get("success_criteria"):
|
||||
@@ -1147,309 +1105,20 @@ def register_queen_lifecycle_tools(
|
||||
registry.register("replan_agent", _replan_tool, lambda inputs: replan_agent())
|
||||
tools_registered += 1
|
||||
|
||||
# --- Flowchart file persistence -------------------------------------------
|
||||
# The flowchart is saved as flowchart.json in the agent's folder so it
|
||||
# survives restarts and is available when loading any agent.
|
||||
|
||||
FLOWCHART_FILENAME = "flowchart.json"
|
||||
|
||||
def _save_flowchart_file(
|
||||
agent_path: Path | str | None,
|
||||
original_draft: dict,
|
||||
flowchart_map: dict[str, list[str]] | None,
|
||||
) -> None:
|
||||
"""Persist the flowchart to the agent's folder."""
|
||||
if agent_path is None:
|
||||
return
|
||||
p = Path(agent_path)
|
||||
if not p.is_dir():
|
||||
return
|
||||
try:
|
||||
target = p / FLOWCHART_FILENAME
|
||||
target.write_text(
|
||||
json.dumps(
|
||||
{"original_draft": original_draft, "flowchart_map": flowchart_map},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.debug("Flowchart saved to %s", target)
|
||||
except Exception:
|
||||
logger.warning("Failed to save flowchart to %s", p, exc_info=True)
|
||||
|
||||
def _load_flowchart_file(
|
||||
agent_path: Path | str | None,
|
||||
) -> tuple[dict | None, dict[str, list[str]] | None]:
|
||||
"""Load flowchart from the agent's folder. Returns (original_draft, flowchart_map)."""
|
||||
if agent_path is None:
|
||||
return None, None
|
||||
target = Path(agent_path) / FLOWCHART_FILENAME
|
||||
if not target.is_file():
|
||||
return None, None
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
return data.get("original_draft"), data.get("flowchart_map")
|
||||
except Exception:
|
||||
logger.warning("Failed to load flowchart from %s", target, exc_info=True)
|
||||
return None, None
|
||||
|
||||
def _synthesize_draft_from_runtime(
|
||||
runtime_nodes: list,
|
||||
runtime_edges: list,
|
||||
agent_name: str = "",
|
||||
goal_name: str = "",
|
||||
) -> tuple[dict, dict[str, list[str]]]:
|
||||
"""Generate a flowchart draft from a loaded runtime graph.
|
||||
|
||||
Used for agents that were never planned through the draft workflow
|
||||
(e.g., hand-coded or loaded from "my agents"). Produces a valid
|
||||
DraftGraph structure with auto-classified flowchart types.
|
||||
"""
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
node_ids = {n.id for n in runtime_nodes}
|
||||
|
||||
# Build edge dicts first (needed for classification)
|
||||
for i, re in enumerate(runtime_edges):
|
||||
edges.append(
|
||||
{
|
||||
"id": f"edge-{i}",
|
||||
"source": re.source,
|
||||
"target": re.target,
|
||||
"condition": str(re.condition.value)
|
||||
if hasattr(re.condition, "value")
|
||||
else str(re.condition),
|
||||
"description": getattr(re, "description", "") or "",
|
||||
"label": "",
|
||||
}
|
||||
)
|
||||
|
||||
# Terminal detection — exclude sub-agent nodes (they are leaf helpers, not endpoints)
|
||||
sub_agent_ids: set[str] = set()
|
||||
for rn in runtime_nodes:
|
||||
for sa_id in getattr(rn, "sub_agents", None) or []:
|
||||
sub_agent_ids.add(sa_id)
|
||||
sources = {e["source"] for e in edges}
|
||||
terminal_ids = node_ids - sources - sub_agent_ids
|
||||
if not terminal_ids and runtime_nodes:
|
||||
terminal_ids = {runtime_nodes[-1].id}
|
||||
|
||||
# Build node dicts with classification
|
||||
total = len(runtime_nodes)
|
||||
for i, rn in enumerate(runtime_nodes):
|
||||
node: dict = {
|
||||
"id": rn.id,
|
||||
"name": rn.name,
|
||||
"description": rn.description or "",
|
||||
"node_type": getattr(rn, "node_type", "event_loop") or "event_loop",
|
||||
"tools": list(rn.tools) if rn.tools else [],
|
||||
"input_keys": list(rn.input_keys) if rn.input_keys else [],
|
||||
"output_keys": list(rn.output_keys) if rn.output_keys else [],
|
||||
"success_criteria": getattr(rn, "success_criteria", "") or "",
|
||||
"sub_agents": list(rn.sub_agents) if getattr(rn, "sub_agents", None) else [],
|
||||
}
|
||||
fc_type = _classify_flowchart_node(node, i, total, edges, terminal_ids)
|
||||
fc_meta = _FLOWCHART_TYPES[fc_type]
|
||||
node["flowchart_type"] = fc_type
|
||||
node["flowchart_shape"] = fc_meta["shape"]
|
||||
node["flowchart_color"] = fc_meta["color"]
|
||||
nodes.append(node)
|
||||
|
||||
# Add visual edges from parent nodes to their sub_agents.
|
||||
# Sub-agents are connected via the sub_agents field, not via EdgeSpec,
|
||||
# so they'd appear as disconnected islands without this.
|
||||
# Two edges per sub-agent: delegate (parent→sub) and report (sub→parent).
|
||||
edge_counter = len(edges)
|
||||
for node in nodes:
|
||||
for sa_id in node.get("sub_agents") or []:
|
||||
if sa_id in node_ids:
|
||||
edges.append(
|
||||
{
|
||||
"id": f"edge-subagent-{edge_counter}",
|
||||
"source": node["id"],
|
||||
"target": sa_id,
|
||||
"condition": "always",
|
||||
"description": "sub-agent delegation",
|
||||
"label": "delegate",
|
||||
}
|
||||
)
|
||||
edge_counter += 1
|
||||
edges.append(
|
||||
{
|
||||
"id": f"edge-subagent-{edge_counter}",
|
||||
"source": sa_id,
|
||||
"target": node["id"],
|
||||
"condition": "always",
|
||||
"description": "sub-agent report back",
|
||||
"label": "report",
|
||||
}
|
||||
)
|
||||
edge_counter += 1
|
||||
|
||||
# Group sub-agent nodes under their parent in the flowchart map
|
||||
# (mirrors what _dissolve_planning_nodes does for planned drafts)
|
||||
sub_agent_ids: set[str] = set()
|
||||
for node in nodes:
|
||||
for sa_id in node.get("sub_agents") or []:
|
||||
if sa_id in node_ids:
|
||||
sub_agent_ids.add(sa_id)
|
||||
|
||||
fmap: dict[str, list[str]] = {}
|
||||
for node in nodes:
|
||||
nid = node["id"]
|
||||
if nid in sub_agent_ids:
|
||||
continue # skip — will be included via parent
|
||||
absorbed = [nid]
|
||||
for sa_id in node.get("sub_agents") or []:
|
||||
if sa_id in node_ids:
|
||||
absorbed.append(sa_id)
|
||||
fmap[nid] = absorbed
|
||||
|
||||
draft = {
|
||||
"agent_name": agent_name,
|
||||
"goal": goal_name,
|
||||
"description": "",
|
||||
"success_criteria": [],
|
||||
"constraints": [],
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"entry_node": nodes[0]["id"] if nodes else "",
|
||||
"terminal_nodes": sorted(terminal_ids),
|
||||
"flowchart_legend": {
|
||||
fc_type: {"shape": meta["shape"], "color": meta["color"]}
|
||||
for fc_type, meta in _FLOWCHART_TYPES.items()
|
||||
},
|
||||
}
|
||||
|
||||
return draft, fmap
|
||||
# --- Flowchart utilities ---------------------------------------------------
|
||||
# Flowchart persistence, classification, and synthesis functions are now in
|
||||
# framework.tools.flowchart_utils. Local aliases for backward compatibility
|
||||
# within this closure:
|
||||
_save_flowchart_file = save_flowchart_file
|
||||
_load_flowchart_file = load_flowchart_file
|
||||
_synthesize_draft_from_runtime = synthesize_draft_from_runtime
|
||||
_classify_flowchart_node = classify_flowchart_node
|
||||
|
||||
# --- save_agent_draft (Planning phase — declarative graph preview) ---------
|
||||
# Creates a lightweight draft graph with nodes, edges, and business metadata.
|
||||
# Loose validation: only requires names and descriptions. Emits an event
|
||||
# so the frontend can render the graph during planning (before any code).
|
||||
|
||||
def _classify_flowchart_node(
|
||||
node: dict,
|
||||
index: int,
|
||||
total: int,
|
||||
edges: list[dict],
|
||||
terminal_ids: set[str],
|
||||
) -> str:
|
||||
"""Auto-detect the ISO 5807 flowchart type for a draft node.
|
||||
|
||||
Priority: explicit override > structural detection > heuristic > default.
|
||||
"""
|
||||
# Explicit override from the queen
|
||||
explicit = node.get("flowchart_type", "").strip()
|
||||
if explicit and explicit in _FLOWCHART_TYPES:
|
||||
return explicit
|
||||
|
||||
node_id = node["id"]
|
||||
node_type = node.get("node_type", "event_loop")
|
||||
node_tools = set(node.get("tools") or [])
|
||||
desc = (node.get("description") or "").lower()
|
||||
name = (node.get("name") or "").lower()
|
||||
|
||||
# GCU / browser automation nodes → hexagon
|
||||
if node_type == "gcu":
|
||||
return "browser"
|
||||
|
||||
# Entry node (first node or no incoming edges) → start terminator
|
||||
incoming = {e["target"] for e in edges}
|
||||
if index == 0 or (node_id not in incoming and index == 0):
|
||||
return "start"
|
||||
|
||||
# Terminal node → end terminator
|
||||
if node_id in terminal_ids:
|
||||
return "terminal"
|
||||
|
||||
# Decision node: has outgoing edges with branching conditions → diamond
|
||||
outgoing = [e for e in edges if e["source"] == node_id]
|
||||
if len(outgoing) >= 2:
|
||||
conditions = {e.get("condition", "on_success") for e in outgoing}
|
||||
if len(conditions) > 1 or conditions - {"on_success"}:
|
||||
return "decision"
|
||||
|
||||
# Sub-agent / subprocess nodes → subroutine (double-bordered rect)
|
||||
if node.get("sub_agents"):
|
||||
return "subprocess"
|
||||
|
||||
# Database / data store nodes → cylinder
|
||||
db_tool_hints = {
|
||||
"query_database",
|
||||
"sql_query",
|
||||
"read_table",
|
||||
"write_table",
|
||||
"save_data",
|
||||
"load_data",
|
||||
}
|
||||
db_desc_hints = {"database", "data store", "storage", "persist", "cache"}
|
||||
if node_tools & db_tool_hints or any(h in desc for h in db_desc_hints):
|
||||
return "database"
|
||||
|
||||
# Document generation nodes → document shape
|
||||
doc_tool_hints = {
|
||||
"generate_report",
|
||||
"create_document",
|
||||
"write_report",
|
||||
"render_template",
|
||||
"export_pdf",
|
||||
}
|
||||
doc_desc_hints = {"report", "document", "summary", "write up", "writeup"}
|
||||
if node_tools & doc_tool_hints or any(h in desc for h in doc_desc_hints):
|
||||
return "document"
|
||||
|
||||
# I/O nodes: external data ingestion or delivery → parallelogram
|
||||
io_tool_hints = {
|
||||
"serve_file_to_user",
|
||||
"send_email",
|
||||
"post_message",
|
||||
"upload_file",
|
||||
"download_file",
|
||||
"fetch_url",
|
||||
"post_to_slack",
|
||||
"send_notification",
|
||||
}
|
||||
io_desc_hints = {"deliver", "send", "output", "notify", "publish"}
|
||||
if node_tools & io_tool_hints or any(h in desc for h in io_desc_hints):
|
||||
return "io"
|
||||
|
||||
# Manual / human-in-the-loop nodes → trapezoid
|
||||
manual_desc_hints = {
|
||||
"human review",
|
||||
"manual",
|
||||
"approval",
|
||||
"human-in-the-loop",
|
||||
"user review",
|
||||
"manual check",
|
||||
}
|
||||
if any(h in desc for h in manual_desc_hints) or any(h in name for h in manual_desc_hints):
|
||||
return "manual_operation"
|
||||
|
||||
# Preparation / setup nodes → hexagon
|
||||
prep_desc_hints = {"setup", "initialize", "prepare", "configure", "provision"}
|
||||
if any(h in desc for h in prep_desc_hints) or any(h in name for h in prep_desc_hints):
|
||||
return "preparation"
|
||||
|
||||
# Delay / wait nodes → D-shape
|
||||
delay_desc_hints = {"wait", "delay", "pause", "cooldown", "throttle", "sleep"}
|
||||
if any(h in desc for h in delay_desc_hints):
|
||||
return "delay"
|
||||
|
||||
# Merge nodes → inverted triangle
|
||||
merge_desc_hints = {"merge", "combine", "aggregate", "consolidate"}
|
||||
if any(h in desc for h in merge_desc_hints) or any(h in name for h in merge_desc_hints):
|
||||
return "merge"
|
||||
|
||||
# Display nodes → display shape
|
||||
display_desc_hints = {"display", "show", "present", "render", "visualize"}
|
||||
display_tool_hints = {"serve_file_to_user", "display_results"}
|
||||
if node_tools & display_tool_hints or any(h in name for h in display_desc_hints):
|
||||
return "display"
|
||||
|
||||
# Default: process (rectangle)
|
||||
return "process"
|
||||
|
||||
def _dissolve_planning_nodes(
|
||||
draft: dict,
|
||||
) -> tuple[dict, dict[str, list[str]]]:
|
||||
@@ -1535,7 +1204,7 @@ def register_queen_lifecycle_tools(
|
||||
if not predecessors:
|
||||
# Decision at start: convert to regular process node
|
||||
d_node["flowchart_type"] = "process"
|
||||
fc_meta = _FLOWCHART_TYPES["process"]
|
||||
fc_meta = FLOWCHART_TYPES["process"]
|
||||
d_node["flowchart_shape"] = fc_meta["shape"]
|
||||
d_node["flowchart_color"] = fc_meta["color"]
|
||||
if not d_node.get("success_criteria"):
|
||||
@@ -2087,7 +1756,7 @@ def register_queen_lifecycle_tools(
|
||||
validated_edges,
|
||||
terminal_ids,
|
||||
)
|
||||
fc_meta = _FLOWCHART_TYPES[fc_type]
|
||||
fc_meta = FLOWCHART_TYPES[fc_type]
|
||||
node["flowchart_type"] = fc_type
|
||||
node["flowchart_shape"] = fc_meta["shape"]
|
||||
node["flowchart_color"] = fc_meta["color"]
|
||||
@@ -2105,7 +1774,7 @@ def register_queen_lifecycle_tools(
|
||||
# Color legend for the frontend
|
||||
"flowchart_legend": {
|
||||
fc_type: {"shape": meta["shape"], "color": meta["color"]}
|
||||
for fc_type, meta in _FLOWCHART_TYPES.items()
|
||||
for fc_type, meta in FLOWCHART_TYPES.items()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2276,39 +1945,18 @@ def register_queen_lifecycle_tools(
|
||||
"decision",
|
||||
"io",
|
||||
"document",
|
||||
"multi_document",
|
||||
"subprocess",
|
||||
"preparation",
|
||||
"manual_input",
|
||||
"manual_operation",
|
||||
"delay",
|
||||
"display",
|
||||
"database",
|
||||
"stored_data",
|
||||
"internal_storage",
|
||||
"connector",
|
||||
"offpage_connector",
|
||||
"merge",
|
||||
"extract",
|
||||
"sort",
|
||||
"collate",
|
||||
"summing_junction",
|
||||
"or",
|
||||
"subprocess",
|
||||
"browser",
|
||||
"comment",
|
||||
"alternate_process",
|
||||
],
|
||||
"description": (
|
||||
"ISO 5807 flowchart symbol type. Auto-detected if omitted. "
|
||||
"Core: start (green stadium), terminal (red stadium), "
|
||||
"process (blue rect), decision (amber diamond), "
|
||||
"io (purple parallelogram), document (grey wavy rect), "
|
||||
"subprocess (teal subroutine), preparation (brown hexagon), "
|
||||
"manual_operation (pink trapezoid), delay (orange D-shape), "
|
||||
"display (cyan), database (green cylinder), "
|
||||
"merge (indigo triangle), browser (dark indigo hexagon — "
|
||||
"for GCU/browser sub-agents; must be a leaf node connected "
|
||||
"only to its managing parent)"
|
||||
"Flowchart symbol type. Auto-detected if omitted. "
|
||||
"start (sage green stadium), terminal (dusty red stadium), "
|
||||
"process (blue-gray rect), decision (amber diamond), "
|
||||
"io (purple parallelogram), document (steel blue wavy rect), "
|
||||
"database (teal cylinder), subprocess (cyan subroutine), "
|
||||
"browser (deep blue hexagon — for GCU/browser "
|
||||
"sub-agents; must be a leaf node)"
|
||||
),
|
||||
},
|
||||
"tools": {
|
||||
@@ -4054,6 +3702,8 @@ def register_queen_lifecycle_tools(
|
||||
_save_trigger_to_agent(session, trigger_id, tdef)
|
||||
bus = getattr(session, "event_bus", None)
|
||||
if bus:
|
||||
_runner = getattr(session, "runner", None)
|
||||
_graph_entry = _runner.graph.entry_node if _runner else None
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.TRIGGER_ACTIVATED,
|
||||
@@ -4062,6 +3712,8 @@ def register_queen_lifecycle_tools(
|
||||
"trigger_id": trigger_id,
|
||||
"trigger_type": t_type,
|
||||
"trigger_config": t_config,
|
||||
"name": tdef.description or trigger_id,
|
||||
**({"entry_node": _graph_entry} if _graph_entry else {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -4114,6 +3766,8 @@ def register_queen_lifecycle_tools(
|
||||
# Emit event
|
||||
bus = getattr(session, "event_bus", None)
|
||||
if bus:
|
||||
_runner = getattr(session, "runner", None)
|
||||
_graph_entry = _runner.graph.entry_node if _runner else None
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.TRIGGER_ACTIVATED,
|
||||
@@ -4122,6 +3776,8 @@ def register_queen_lifecycle_tools(
|
||||
"trigger_id": trigger_id,
|
||||
"trigger_type": t_type,
|
||||
"trigger_config": t_config,
|
||||
"name": tdef.description or trigger_id,
|
||||
**({"entry_node": _graph_entry} if _graph_entry else {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -4220,7 +3876,10 @@ def register_queen_lifecycle_tools(
|
||||
AgentEvent(
|
||||
type=EventType.TRIGGER_DEACTIVATED,
|
||||
stream_id="queen",
|
||||
data={"trigger_id": trigger_id},
|
||||
data={
|
||||
"trigger_id": trigger_id,
|
||||
"name": tdef.description or trigger_id if tdef else trigger_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Tool for the queen to write to her episodic memory.
|
||||
"""Tools for the queen to read and write episodic memory.
|
||||
|
||||
The queen can consciously record significant moments during a session — like
|
||||
writing in a diary. Semantic memory (MEMORY.md) is updated automatically at
|
||||
session end and is never written by the queen directly.
|
||||
writing in a diary — and recall past diary entries when needed. Semantic
|
||||
memory (MEMORY.md) is updated automatically at session end and is never
|
||||
written by the queen directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,6 +34,67 @@ def write_to_diary(entry: str) -> str:
|
||||
return "Diary entry recorded."
|
||||
|
||||
|
||||
def recall_diary(query: str = "", days_back: int = 7) -> str:
|
||||
"""Search recent diary entries (episodic memory).
|
||||
|
||||
Use this when the user asks about what happened in the past — "what did we
|
||||
do yesterday?", "what happened last week?", "remind me about the pipeline
|
||||
issue", etc. Also use it proactively when you need context from recent
|
||||
sessions to answer a question or make a decision.
|
||||
|
||||
Args:
|
||||
query: Optional keyword or phrase to filter entries. If empty, all
|
||||
recent entries are returned.
|
||||
days_back: How many days to look back (1–30). Defaults to 7.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from framework.agents.queen.queen_memory import read_episodic_memory
|
||||
|
||||
days_back = max(1, min(days_back, 30))
|
||||
today = date.today()
|
||||
results: list[str] = []
|
||||
total_chars = 0
|
||||
char_budget = 12_000
|
||||
|
||||
for offset in range(days_back):
|
||||
d = today - timedelta(days=offset)
|
||||
content = read_episodic_memory(d)
|
||||
if not content:
|
||||
continue
|
||||
# If a query is given, only include entries that mention it
|
||||
if query:
|
||||
# Check each section (split by ###) for relevance
|
||||
sections = content.split("### ")
|
||||
matched = [s for s in sections if query.lower() in s.lower()]
|
||||
if not matched:
|
||||
continue
|
||||
content = "### ".join(matched)
|
||||
label = d.strftime("%B %-d, %Y")
|
||||
if d == today:
|
||||
label = f"Today — {label}"
|
||||
entry = f"## {label}\n\n{content}"
|
||||
if total_chars + len(entry) > char_budget:
|
||||
remaining = char_budget - total_chars
|
||||
if remaining > 200:
|
||||
# Fit a partial entry within budget
|
||||
trimmed = content[: remaining - 100] + "\n\n…(truncated)"
|
||||
results.append(f"## {label}\n\n{trimmed}")
|
||||
else:
|
||||
results.append(f"## {label}\n\n(truncated — hit size limit)")
|
||||
break
|
||||
results.append(entry)
|
||||
total_chars += len(entry)
|
||||
|
||||
if not results:
|
||||
if query:
|
||||
return f"No diary entries matching '{query}' in the last {days_back} days."
|
||||
return f"No diary entries found in the last {days_back} days."
|
||||
|
||||
return "\n\n---\n\n".join(results)
|
||||
|
||||
|
||||
def register_queen_memory_tools(registry: ToolRegistry) -> None:
|
||||
"""Register the episodic memory tool into the queen's tool registry."""
|
||||
"""Register the episodic memory tools into the queen's tool registry."""
|
||||
registry.register_function(write_to_diary)
|
||||
registry.register_function(recall_diary)
|
||||
|
||||
@@ -64,10 +64,14 @@ export const sessionsApi = {
|
||||
`/sessions/${sessionId}/entry-points`,
|
||||
),
|
||||
|
||||
updateTriggerTask: (sessionId: string, triggerId: string, task: string) =>
|
||||
api.patch<{ trigger_id: string; task: string }>(
|
||||
updateTrigger: (
|
||||
sessionId: string,
|
||||
triggerId: string,
|
||||
patch: { task?: string; trigger_config?: Record<string, unknown> },
|
||||
) =>
|
||||
api.patch<{ trigger_id: string; task: string; trigger_config: Record<string, unknown> }>(
|
||||
`/sessions/${sessionId}/triggers/${triggerId}`,
|
||||
{ task },
|
||||
patch,
|
||||
),
|
||||
|
||||
graphs: (sessionId: string) =>
|
||||
|
||||
@@ -337,7 +337,8 @@ export type EventTypeName =
|
||||
| "trigger_activated"
|
||||
| "trigger_deactivated"
|
||||
| "trigger_fired"
|
||||
| "trigger_removed";
|
||||
| "trigger_removed"
|
||||
| "trigger_updated";
|
||||
|
||||
export interface AgentEvent {
|
||||
type: EventTypeName;
|
||||
|
||||
@@ -1,770 +0,0 @@
|
||||
import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Play, Pause, Loader2, CheckCircle2 } from "lucide-react";
|
||||
|
||||
export type NodeStatus = "running" | "complete" | "pending" | "error" | "looping";
|
||||
|
||||
export type NodeType = "execution" | "trigger";
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
status: NodeStatus;
|
||||
nodeType?: NodeType;
|
||||
triggerType?: string;
|
||||
triggerConfig?: Record<string, unknown>;
|
||||
next?: string[];
|
||||
backEdges?: string[];
|
||||
iterations?: number;
|
||||
maxIterations?: number;
|
||||
statusLabel?: string;
|
||||
edgeLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type RunState = "idle" | "deploying" | "running";
|
||||
|
||||
interface AgentGraphProps {
|
||||
nodes: GraphNode[];
|
||||
title: string;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onRun?: () => void;
|
||||
onPause?: () => void;
|
||||
version?: string;
|
||||
runState?: RunState;
|
||||
building?: boolean;
|
||||
queenPhase?: "planning" | "building" | "staging" | "running";
|
||||
}
|
||||
|
||||
// --- Extracted RunButton so hover state survives parent re-renders ---
|
||||
export interface RunButtonProps {
|
||||
runState: RunState;
|
||||
disabled: boolean;
|
||||
onRun: () => void;
|
||||
onPause: () => void;
|
||||
btnRef: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export const RunButton = memo(function RunButton({ runState, disabled, onRun, onPause, btnRef }: RunButtonProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const showPause = runState === "running" && hovered;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={runState === "running" ? onPause : onRun}
|
||||
disabled={runState === "deploying" || disabled}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all duration-200 ${
|
||||
showPause
|
||||
? "bg-amber-500/15 text-amber-400 border border-amber-500/40 hover:bg-amber-500/25 active:scale-95 cursor-pointer"
|
||||
: runState === "running"
|
||||
? "bg-green-500/15 text-green-400 border border-green-500/30 cursor-pointer"
|
||||
: runState === "deploying"
|
||||
? "bg-primary/10 text-primary border border-primary/20 cursor-default"
|
||||
: disabled
|
||||
? "bg-muted/30 text-muted-foreground/40 border border-border/20 cursor-not-allowed"
|
||||
: "bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 hover:border-primary/40 active:scale-95"
|
||||
}`}
|
||||
>
|
||||
{runState === "deploying" ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : showPause ? (
|
||||
<Pause className="w-3 h-3 fill-current" />
|
||||
) : runState === "running" ? (
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
) : (
|
||||
<Play className="w-3 h-3 fill-current" />
|
||||
)}
|
||||
{runState === "deploying" ? "Deploying\u2026" : showPause ? "Pause" : runState === "running" ? "Running" : "Run"}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
const NODE_W_MAX = 180;
|
||||
const NODE_H = 44;
|
||||
const GAP_Y = 48;
|
||||
const TOP_Y = 30;
|
||||
const MARGIN_LEFT = 20;
|
||||
const MARGIN_RIGHT = 50; // space for back-edge arcs
|
||||
const SVG_BASE_W = 320;
|
||||
const GAP_X = 12;
|
||||
|
||||
// Read a CSS custom property value (space-separated HSL components)
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
type StatusColorSet = Record<NodeStatus, { dot: string; bg: string; border: string; glow: string }>;
|
||||
type TriggerColorSet = { bg: string; border: string; text: string; icon: string };
|
||||
|
||||
function buildStatusColors(): StatusColorSet {
|
||||
const running = cssVar("--node-running") || "45 95% 58%";
|
||||
const looping = cssVar("--node-looping") || "38 90% 55%";
|
||||
const complete = cssVar("--node-complete") || "43 70% 45%";
|
||||
const pending = cssVar("--node-pending") || "35 15% 28%";
|
||||
const pendingBg = cssVar("--node-pending-bg") || "35 10% 12%";
|
||||
const pendingBorder = cssVar("--node-pending-border") || "35 10% 20%";
|
||||
const error = cssVar("--node-error") || "0 65% 55%";
|
||||
|
||||
return {
|
||||
running: {
|
||||
dot: `hsl(${running})`,
|
||||
bg: `hsl(${running} / 0.08)`,
|
||||
border: `hsl(${running} / 0.5)`,
|
||||
glow: `hsl(${running} / 0.15)`,
|
||||
},
|
||||
looping: {
|
||||
dot: `hsl(${looping})`,
|
||||
bg: `hsl(${looping} / 0.08)`,
|
||||
border: `hsl(${looping} / 0.5)`,
|
||||
glow: `hsl(${looping} / 0.15)`,
|
||||
},
|
||||
complete: {
|
||||
dot: `hsl(${complete})`,
|
||||
bg: `hsl(${complete} / 0.05)`,
|
||||
border: `hsl(${complete} / 0.25)`,
|
||||
glow: "none",
|
||||
},
|
||||
pending: {
|
||||
dot: `hsl(${pending})`,
|
||||
bg: `hsl(${pendingBg})`,
|
||||
border: `hsl(${pendingBorder})`,
|
||||
glow: "none",
|
||||
},
|
||||
error: {
|
||||
dot: `hsl(${error})`,
|
||||
bg: `hsl(${error} / 0.06)`,
|
||||
border: `hsl(${error} / 0.3)`,
|
||||
glow: `hsl(${error} / 0.1)`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTriggerColors(): TriggerColorSet {
|
||||
const bg = cssVar("--trigger-bg") || "210 25% 14%";
|
||||
const border = cssVar("--trigger-border") || "210 30% 30%";
|
||||
const text = cssVar("--trigger-text") || "210 30% 65%";
|
||||
const icon = cssVar("--trigger-icon") || "210 40% 55%";
|
||||
return {
|
||||
bg: `hsl(${bg})`,
|
||||
border: `hsl(${border})`,
|
||||
text: `hsl(${text})`,
|
||||
icon: `hsl(${icon})`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hook that reads node/trigger colors from CSS vars and updates on theme changes. */
|
||||
function useThemeColors() {
|
||||
const [statusColors, setStatusColors] = useState<StatusColorSet>(buildStatusColors);
|
||||
const [triggerColors, setTriggerColors] = useState<TriggerColorSet>(buildTriggerColors);
|
||||
|
||||
useEffect(() => {
|
||||
const rebuild = () => {
|
||||
setStatusColors(buildStatusColors());
|
||||
setTriggerColors(buildTriggerColors());
|
||||
};
|
||||
const obs = new MutationObserver(rebuild);
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
return { statusColors, triggerColors };
|
||||
}
|
||||
|
||||
// Active trigger — brighter, more saturated blue
|
||||
const activeTriggerColors = {
|
||||
bg: "hsl(210,30%,18%)",
|
||||
border: "hsl(210,50%,50%)",
|
||||
text: "hsl(210,40%,75%)",
|
||||
icon: "hsl(210,60%,65%)",
|
||||
};
|
||||
|
||||
const triggerIcons: Record<string, string> = {
|
||||
webhook: "\u26A1", // lightning bolt
|
||||
timer: "\u23F1", // stopwatch
|
||||
api: "\u2192", // right arrow
|
||||
event: "\u223F", // sine wave
|
||||
};
|
||||
|
||||
/** 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";
|
||||
}
|
||||
|
||||
// ─── Pan & Zoom wrapper ───
|
||||
function PanZoomSvg({ svgW, svgH, className, children }: { svgW: number; svgH: number; className?: string; children: React.ReactNode }) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
||||
|
||||
const MIN_ZOOM = 0.4;
|
||||
const MAX_ZOOM = 3;
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setZoom(z => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z * delta)));
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
setDragging(true);
|
||||
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
|
||||
}, [pan]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
setPan({
|
||||
x: dragStart.current.panX + (e.clientX - dragStart.current.x),
|
||||
y: dragStart.current.panY + (e.clientY - dragStart.current.y),
|
||||
});
|
||||
}, [dragging]);
|
||||
|
||||
const handleMouseUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 relative overflow-hidden px-1 pb-5">
|
||||
<div
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className="w-full h-full"
|
||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${svgW} ${svgH}`}
|
||||
preserveAspectRatio="xMidYMin meet"
|
||||
className={`select-none ${className || ""}`}
|
||||
style={{
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
transformOrigin: "center top",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="absolute bottom-7 right-3 flex items-center gap-1 bg-card/80 backdrop-blur-sm border border-border/40 rounded-lg p-0.5 shadow-sm">
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(MAX_ZOOM, z * 1.2))}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors text-xs font-bold"
|
||||
aria-label="Zoom in"
|
||||
>+</button>
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="px-1.5 h-6 flex items-center justify-center rounded text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
aria-label="Reset zoom"
|
||||
>{Math.round(zoom * 100)}%</button>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(MIN_ZOOM, z * 0.8))}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors text-xs font-bold"
|
||||
aria-label="Zoom out"
|
||||
>{"\u2212"}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, onPause, version, runState: externalRunState, building, queenPhase }: AgentGraphProps) {
|
||||
const [localRunState, setLocalRunState] = useState<RunState>("idle");
|
||||
const runState = externalRunState ?? localRunState;
|
||||
const runBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const { statusColors, triggerColors } = useThemeColors();
|
||||
|
||||
const handleRun = () => {
|
||||
if (runState !== "idle") return;
|
||||
if (onRun) {
|
||||
onRun();
|
||||
} else {
|
||||
setLocalRunState("deploying");
|
||||
setTimeout(() => setLocalRunState("running"), 1800);
|
||||
setTimeout(() => setLocalRunState("idle"), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const idxMap = useMemo(() => Object.fromEntries(nodes.map((n, i) => [n.id, i])), [nodes]);
|
||||
|
||||
const backEdges = useMemo(() => {
|
||||
const edges: { fromIdx: number; toIdx: number }[] = [];
|
||||
nodes.forEach((n, i) => {
|
||||
(n.next || []).forEach((toId) => {
|
||||
const toIdx = idxMap[toId];
|
||||
if (toIdx !== undefined && toIdx <= i) edges.push({ fromIdx: i, toIdx });
|
||||
});
|
||||
(n.backEdges || []).forEach((toId) => {
|
||||
const toIdx = idxMap[toId];
|
||||
if (toIdx !== undefined) edges.push({ fromIdx: i, toIdx });
|
||||
});
|
||||
});
|
||||
return edges;
|
||||
}, [nodes, idxMap]);
|
||||
|
||||
const forwardEdges = useMemo(() => {
|
||||
const edges: { fromIdx: number; toIdx: number; fanCount: number; fanIndex: number; label?: string }[] = [];
|
||||
nodes.forEach((n, i) => {
|
||||
const targets = (n.next || [])
|
||||
.map((toId) => ({ toId, toIdx: idxMap[toId] }))
|
||||
.filter((t): t is { toId: string; toIdx: number } => t.toIdx !== undefined && t.toIdx > i);
|
||||
targets.forEach(({ toId, toIdx }, fi) => {
|
||||
edges.push({
|
||||
fromIdx: i,
|
||||
toIdx,
|
||||
fanCount: targets.length,
|
||||
fanIndex: fi,
|
||||
label: n.edgeLabels?.[toId],
|
||||
});
|
||||
});
|
||||
});
|
||||
return edges;
|
||||
}, [nodes, idxMap]);
|
||||
|
||||
// --- Layer-based layout computation ---
|
||||
const layout = useMemo(() => {
|
||||
if (nodes.length === 0) {
|
||||
return { layers: [] as number[], cols: [] as number[], maxCols: 1, nodeW: NODE_W_MAX, colSpacing: 0, firstColX: MARGIN_LEFT };
|
||||
}
|
||||
|
||||
// 1. Build reverse adjacency from forward edges (who are the parents of each node)
|
||||
const parents = new Map<number, number[]>();
|
||||
nodes.forEach((_, i) => parents.set(i, []));
|
||||
forwardEdges.forEach((e) => {
|
||||
parents.get(e.toIdx)!.push(e.fromIdx);
|
||||
});
|
||||
|
||||
// 2. Assign layers via longest-path from entry
|
||||
const layers = new Array(nodes.length).fill(0);
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const pars = parents.get(i) || [];
|
||||
if (pars.length > 0) {
|
||||
layers[i] = Math.max(...pars.map((p) => layers[p])) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Group nodes by layer
|
||||
const layerGroups = new Map<number, number[]>();
|
||||
layers.forEach((l, i) => {
|
||||
const group = layerGroups.get(l) || [];
|
||||
group.push(i);
|
||||
layerGroups.set(l, group);
|
||||
});
|
||||
|
||||
// 4. Compute max columns and dynamic node width
|
||||
let maxCols = 1;
|
||||
layerGroups.forEach((group) => {
|
||||
maxCols = Math.max(maxCols, group.length);
|
||||
});
|
||||
|
||||
const usableW = SVG_BASE_W - MARGIN_LEFT - MARGIN_RIGHT;
|
||||
const nodeW = Math.min(NODE_W_MAX, Math.floor((usableW - (maxCols - 1) * GAP_X) / maxCols));
|
||||
const colSpacing = nodeW + GAP_X;
|
||||
const totalNodesW = maxCols * nodeW + (maxCols - 1) * GAP_X;
|
||||
const firstColX = MARGIN_LEFT + (usableW - totalNodesW) / 2;
|
||||
|
||||
// 5. Assign columns within each layer (centered, ordered by parent column)
|
||||
const cols = new Array(nodes.length).fill(0);
|
||||
layerGroups.forEach((group) => {
|
||||
if (group.length === 1) {
|
||||
// Center single node: place at middle column
|
||||
cols[group[0]] = (maxCols - 1) / 2;
|
||||
} else {
|
||||
// Sort group by average parent column to reduce crossings
|
||||
const sorted = [...group].sort((a, b) => {
|
||||
const aParents = parents.get(a) || [];
|
||||
const bParents = parents.get(b) || [];
|
||||
const aAvg = aParents.length > 0 ? aParents.reduce((s, p) => s + cols[p], 0) / aParents.length : 0;
|
||||
const bAvg = bParents.length > 0 ? bParents.reduce((s, p) => s + cols[p], 0) / bParents.length : 0;
|
||||
return aAvg - bAvg;
|
||||
});
|
||||
// Spread evenly, centered within maxCols
|
||||
const offset = (maxCols - group.length) / 2;
|
||||
sorted.forEach((nodeIdx, i) => {
|
||||
cols[nodeIdx] = offset + i;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { layers, cols, maxCols, nodeW, colSpacing, firstColX };
|
||||
}, [nodes, forwardEdges]);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-5 pt-4 pb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">Pipeline</p>
|
||||
{version && (
|
||||
<span className="text-[10px] font-mono font-medium text-muted-foreground/60 border border-border/30 rounded px-1 py-0.5 leading-none">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RunButton runState={runState} disabled={nodes.length === 0 || queenPhase === "building" || queenPhase === "planning"} onRun={handleRun} onPause={onPause ?? (() => {})} btnRef={runBtnRef} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center px-5">
|
||||
{building ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary/60" />
|
||||
<p className="text-xs text-muted-foreground/80 text-center">Building agent...</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/60 text-center italic">No pipeline configured yet.<br/>Chat with the Queen to get started.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { layers, cols, nodeW, colSpacing, firstColX } = layout;
|
||||
|
||||
const nodePos = (i: number) => ({
|
||||
x: firstColX + cols[i] * colSpacing,
|
||||
y: TOP_Y + layers[i] * (NODE_H + GAP_Y),
|
||||
});
|
||||
|
||||
const maxLayer = nodes.length > 0 ? Math.max(...layers) : 0;
|
||||
const svgHeight = TOP_Y * 2 + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y + 10;
|
||||
const backEdgeSpace = backEdges.length > 0 ? MARGIN_RIGHT + backEdges.length * 18 : 20;
|
||||
const svgWidth = Math.max(SVG_BASE_W, firstColX + layout.maxCols * nodeW + (layout.maxCols - 1) * GAP_X + backEdgeSpace);
|
||||
|
||||
// Check if a skip-level forward edge would collide with intermediate nodes
|
||||
const hasCollision = (fromLayer: number, toLayer: number, fromX: number, toX: number): boolean => {
|
||||
const minX = Math.min(fromX, toX);
|
||||
const maxX = Math.max(fromX, toX) + nodeW;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const l = layers[i];
|
||||
if (l > fromLayer && l < toLayer) {
|
||||
const nx = firstColX + cols[i] * colSpacing;
|
||||
// Check horizontal overlap
|
||||
if (nx < maxX && nx + nodeW > minX) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderForwardEdge = (edge: { fromIdx: number; toIdx: number; fanCount: number; fanIndex: number; label?: string }, i: number) => {
|
||||
const from = nodePos(edge.fromIdx);
|
||||
const to = nodePos(edge.toIdx);
|
||||
const fromCenterX = from.x + nodeW / 2;
|
||||
const toCenterX = to.x + nodeW / 2;
|
||||
const y1 = from.y + NODE_H;
|
||||
const y2 = to.y;
|
||||
|
||||
// Fan-out: spread exit points across the source node's bottom
|
||||
let startX = fromCenterX;
|
||||
if (edge.fanCount > 1) {
|
||||
const spread = nodeW * 0.5;
|
||||
const step = edge.fanCount > 1 ? spread / (edge.fanCount - 1) : 0;
|
||||
startX = fromCenterX - spread / 2 + edge.fanIndex * step;
|
||||
}
|
||||
|
||||
const midY = (y1 + y2) / 2;
|
||||
const fromLayer = layers[edge.fromIdx];
|
||||
const toLayer = layers[edge.toIdx];
|
||||
const skipsLayers = toLayer - fromLayer > 1;
|
||||
|
||||
let d: string;
|
||||
if (skipsLayers && hasCollision(fromLayer, toLayer, from.x, to.x)) {
|
||||
// Route around intermediate nodes: orthogonal detour to the left
|
||||
const detourX = Math.min(from.x, to.x) - nodeW * 0.4;
|
||||
d = `M ${startX} ${y1} L ${startX} ${midY} L ${detourX} ${midY} L ${detourX} ${y2 - 10} L ${toCenterX} ${y2 - 10} L ${toCenterX} ${y2}`;
|
||||
} else if (Math.abs(startX - toCenterX) < 2) {
|
||||
// Straight vertical line when aligned
|
||||
d = `M ${startX} ${y1} L ${toCenterX} ${y2}`;
|
||||
} else {
|
||||
// Orthogonal: down, across, down
|
||||
d = `M ${startX} ${y1} L ${startX} ${midY} L ${toCenterX} ${midY} L ${toCenterX} ${y2}`;
|
||||
}
|
||||
|
||||
const fromNode = nodes[edge.fromIdx];
|
||||
const isActive = fromNode.status === "complete" || fromNode.status === "running" || fromNode.status === "looping";
|
||||
const strokeColor = isActive ? statusColors.complete.border : statusColors.pending.border;
|
||||
const arrowColor = isActive ? statusColors.complete.dot : statusColors.pending.border;
|
||||
|
||||
return (
|
||||
<g key={`fwd-${i}`}>
|
||||
<path d={d} fill="none" stroke={strokeColor} strokeWidth={1.5} />
|
||||
<polygon
|
||||
points={`${toCenterX - 4},${y2 - 6} ${toCenterX + 4},${y2 - 6} ${toCenterX},${y2 - 1}`}
|
||||
fill={arrowColor}
|
||||
/>
|
||||
{edge.label && (
|
||||
<text
|
||||
x={(startX + toCenterX) / 2 + 8}
|
||||
y={midY - 2}
|
||||
fill={statusColors.pending.dot}
|
||||
fontSize={9}
|
||||
fontStyle="italic"
|
||||
>
|
||||
{edge.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBackEdge = (edge: { fromIdx: number; toIdx: number }, i: number) => {
|
||||
const from = nodePos(edge.fromIdx);
|
||||
const to = nodePos(edge.toIdx);
|
||||
|
||||
const rightX = Math.max(from.x, to.x) + nodeW;
|
||||
const rightOffset = 28 + i * 18;
|
||||
const startX = from.x + nodeW;
|
||||
const startY = from.y + NODE_H / 2;
|
||||
const endX = to.x + nodeW;
|
||||
const endY = to.y + NODE_H / 2;
|
||||
const curveX = rightX + rightOffset;
|
||||
const r = 12;
|
||||
|
||||
const fromNode = nodes[edge.fromIdx];
|
||||
const isActive = fromNode.status === "complete" || fromNode.status === "running" || fromNode.status === "looping";
|
||||
const color = isActive ? statusColors.looping.border : statusColors.pending.border;
|
||||
|
||||
// Bezier curve with rounded corners (kept as curves for back edges)
|
||||
const path = `M ${startX} ${startY} C ${startX + r} ${startY}, ${curveX} ${startY}, ${curveX} ${startY - r} L ${curveX} ${endY + r} C ${curveX} ${endY}, ${endX + r} ${endY}, ${endX + 6} ${endY}`;
|
||||
|
||||
return (
|
||||
<g key={`back-${i}`}>
|
||||
<path d={path} fill="none" stroke={color} strokeWidth={1.5} strokeDasharray="4 3" />
|
||||
<polygon
|
||||
points={`${endX + 6},${endY - 3} ${endX + 6},${endY + 3} ${endX},${endY}`}
|
||||
fill={isActive ? statusColors.looping.dot : statusColors.pending.border}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTriggerNode = (node: GraphNode, i: number) => {
|
||||
const pos = nodePos(i);
|
||||
const icon = triggerIcons[node.triggerType || ""] || "\u26A1";
|
||||
const triggerFontSize = nodeW < 140 ? 10.5 : 11.5;
|
||||
const triggerAvailW = nodeW - 38;
|
||||
const triggerDisplayLabel = truncateLabel(node.label, triggerAvailW, triggerFontSize);
|
||||
const nextFireIn = node.triggerConfig?.next_fire_in as number | undefined;
|
||||
const isActive = node.status === "running" || node.status === "complete";
|
||||
const colors = isActive ? activeTriggerColors : triggerColors;
|
||||
|
||||
// Format countdown for display below node
|
||||
let countdownLabel: string | null = null;
|
||||
if (isActive && nextFireIn != null && nextFireIn > 0) {
|
||||
const h = Math.floor(nextFireIn / 3600);
|
||||
const m = Math.floor((nextFireIn % 3600) / 60);
|
||||
const s = Math.floor(nextFireIn % 60);
|
||||
countdownLabel = h > 0
|
||||
? `next in ${h}h ${String(m).padStart(2, "0")}m`
|
||||
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
// Status label below countdown
|
||||
const statusLabel = isActive ? "active" : "inactive";
|
||||
const statusColor = isActive ? "hsl(140,40%,50%)" : "hsl(210,20%,40%)";
|
||||
|
||||
return (
|
||||
<g key={node.id} onClick={() => onNodeClick?.(node)} style={{ cursor: onNodeClick ? "pointer" : "default" }}>
|
||||
<title>{node.label}</title>
|
||||
{/* Pill-shaped background — solid border when active, dashed when inactive */}
|
||||
<rect
|
||||
x={pos.x} y={pos.y}
|
||||
width={nodeW} height={NODE_H}
|
||||
rx={NODE_H / 2}
|
||||
fill={colors.bg}
|
||||
stroke={colors.border}
|
||||
strokeWidth={isActive ? 1.5 : 1}
|
||||
strokeDasharray={isActive ? undefined : "4 2"}
|
||||
/>
|
||||
|
||||
{/* Trigger type icon */}
|
||||
<text
|
||||
x={pos.x + 18} y={pos.y + NODE_H / 2}
|
||||
fill={colors.icon} fontSize={13}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
>
|
||||
{icon}
|
||||
</text>
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={pos.x + 32} y={pos.y + NODE_H / 2}
|
||||
fill={colors.text}
|
||||
fontSize={triggerFontSize}
|
||||
fontWeight={500}
|
||||
dominantBaseline="middle"
|
||||
letterSpacing="0.01em"
|
||||
>
|
||||
{triggerDisplayLabel}
|
||||
</text>
|
||||
|
||||
{/* Countdown label below node */}
|
||||
{countdownLabel && (
|
||||
<text
|
||||
x={pos.x + nodeW / 2} y={pos.y + NODE_H + 13}
|
||||
fill={triggerColors.text} fontSize={9.5}
|
||||
textAnchor="middle" fontStyle="italic" opacity={0.7}
|
||||
>
|
||||
{countdownLabel}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Status label */}
|
||||
<text
|
||||
x={pos.x + nodeW / 2} y={pos.y + NODE_H + (countdownLabel ? 25 : 13)}
|
||||
fill={statusColor} fontSize={9}
|
||||
textAnchor="middle" opacity={0.8}
|
||||
>
|
||||
{statusLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNode = (node: GraphNode, i: number) => {
|
||||
if (node.nodeType === "trigger") return renderTriggerNode(node, i);
|
||||
|
||||
const pos = nodePos(i);
|
||||
const isActive = node.status === "running" || node.status === "looping";
|
||||
const isDone = node.status === "complete";
|
||||
const colors = statusColors[node.status];
|
||||
|
||||
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 && (
|
||||
<>
|
||||
<rect
|
||||
x={pos.x - 4} y={pos.y - 4}
|
||||
width={nodeW + 8} height={NODE_H + 8}
|
||||
rx={16} fill={colors.glow}
|
||||
/>
|
||||
<rect
|
||||
x={pos.x - 2} y={pos.y - 2}
|
||||
width={nodeW + 4} height={NODE_H + 4}
|
||||
rx={14} fill="none" stroke={colors.dot} strokeWidth={1} opacity={0.25}
|
||||
style={{ animation: "pulse-ring 2.5s ease-out infinite" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Node background */}
|
||||
<rect
|
||||
x={pos.x} y={pos.y}
|
||||
width={nodeW} height={NODE_H}
|
||||
rx={12}
|
||||
fill={colors.bg}
|
||||
stroke={colors.border}
|
||||
strokeWidth={isActive ? 1.5 : 1}
|
||||
/>
|
||||
|
||||
{/* Status dot */}
|
||||
<circle cx={pos.x + 18} cy={pos.y + NODE_H / 2} r={4.5} fill={colors.dot} />
|
||||
{isActive && (
|
||||
<circle cx={pos.x + 18} cy={pos.y + NODE_H / 2} r={7} fill="none" stroke={colors.dot} strokeWidth={1} opacity={0.3}>
|
||||
<animate attributeName="r" values="7;11;7" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.3;0;0.3" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
|
||||
{/* Check mark for complete */}
|
||||
{isDone && (
|
||||
<text
|
||||
x={pos.x + 18} y={pos.y + NODE_H / 2 + 1}
|
||||
fill={colors.dot} fontSize={8} fontWeight={700}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
>
|
||||
✓
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Label -- truncated with ellipsis for narrow nodes */}
|
||||
<text
|
||||
x={pos.x + 32} y={pos.y + NODE_H / 2}
|
||||
fill={isActive ? statusColors.running.dot : isDone ? statusColors.complete.dot : statusColors.pending.dot}
|
||||
fontSize={fontSize}
|
||||
fontWeight={isActive ? 600 : isDone ? 500 : 400}
|
||||
dominantBaseline="middle"
|
||||
letterSpacing="0.01em"
|
||||
>
|
||||
{displayLabel}
|
||||
</text>
|
||||
|
||||
{/* Status label for active nodes */}
|
||||
{node.statusLabel && isActive && (
|
||||
<text
|
||||
x={pos.x + nodeW + 10} y={pos.y + NODE_H / 2}
|
||||
fill={statusColors.running.dot} fontSize={10.5} fontStyle="italic"
|
||||
dominantBaseline="middle" opacity={0.8}
|
||||
>
|
||||
{node.statusLabel}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Iteration badge */}
|
||||
{node.iterations !== undefined && node.iterations > 0 && (
|
||||
<g>
|
||||
<rect
|
||||
x={pos.x + nodeW - 36} y={pos.y + NODE_H / 2 - 8}
|
||||
width={26} height={16} rx={8}
|
||||
fill={colors.dot} opacity={0.15}
|
||||
/>
|
||||
<text
|
||||
x={pos.x + nodeW - 23} y={pos.y + NODE_H / 2}
|
||||
fill={colors.dot} fontSize={9} fontWeight={600}
|
||||
textAnchor="middle" dominantBaseline="middle" opacity={0.8}
|
||||
>
|
||||
{node.iterations}{node.maxIterations ? `/${node.maxIterations}` : "\u00d7"}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Compact sub-label */}
|
||||
<div className="px-5 pt-4 pb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">Pipeline</p>
|
||||
{version && (
|
||||
<span className="text-[10px] font-mono font-medium text-muted-foreground/60 border border-border/30 rounded px-1 py-0.5 leading-none">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RunButton runState={runState} disabled={nodes.length === 0} onRun={handleRun} onPause={onPause ?? (() => {})} btnRef={runBtnRef} />
|
||||
</div>
|
||||
|
||||
{/* Graph */}
|
||||
<PanZoomSvg svgW={svgWidth} svgH={svgHeight} className={building ? "opacity-30" : ""}>
|
||||
{forwardEdges.map((e, i) => renderForwardEdge(e, i))}
|
||||
{backEdges.map((e, i) => renderBackEdge(e, i))}
|
||||
{nodes.map((n, i) => renderNode(n, i))}
|
||||
</PanZoomSvg>
|
||||
{building && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary/60" />
|
||||
<p className="text-xs text-muted-foreground/80">Rebuilding agent...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { DraftGraph as DraftGraphData, DraftNode } from "@/api/types";
|
||||
import { RunButton } from "./AgentGraph";
|
||||
import type { GraphNode, RunState } from "./AgentGraph";
|
||||
import { RunButton } from "./RunButton";
|
||||
import type { GraphNode, RunState } from "./graph-types";
|
||||
import {
|
||||
cssVar,
|
||||
truncateLabel,
|
||||
TRIGGER_ICONS,
|
||||
ACTIVE_TRIGGER_COLORS,
|
||||
useTriggerColors,
|
||||
} from "@/lib/graphUtils";
|
||||
|
||||
// Read a CSS custom property value (space-separated HSL components)
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
// ── Trigger layout constants ──
|
||||
const TRIGGER_H = 38; // pill height
|
||||
const TRIGGER_PILL_GAP_X = 16; // horizontal gap between multiple trigger pills
|
||||
const TRIGGER_ICON_X = 16; // icon center offset from pill left edge
|
||||
const TRIGGER_LABEL_X = 30; // label start offset from pill left edge
|
||||
const TRIGGER_LABEL_INSET = 38; // icon + padding subtracted from pill width for label space
|
||||
const TRIGGER_TEXT_Y = 11; // y-offset below pill for first text line (countdown or status)
|
||||
const TRIGGER_TEXT_STEP = 11; // additional y-offset for second text line when countdown present
|
||||
const TRIGGER_CLEARANCE = 30; // vertical space below pill for countdown + status text
|
||||
|
||||
interface DraftChromeColors {
|
||||
edge: string;
|
||||
@@ -73,7 +85,9 @@ function useDraftChromeColors() {
|
||||
type DraftNodeStatus = "pending" | "running" | "complete" | "error";
|
||||
|
||||
interface DraftGraphProps {
|
||||
draft: DraftGraphData;
|
||||
draft: DraftGraphData | null;
|
||||
/** The post-build originalDraft — animation fires when this changes to a new non-null value. */
|
||||
originalDraft?: DraftGraphData | null;
|
||||
onNodeClick?: (node: DraftNode) => void;
|
||||
/** Runtime node ID → list of original draft node IDs (post-dissolution mapping). */
|
||||
flowchartMap?: Record<string, string[]>;
|
||||
@@ -83,6 +97,8 @@ interface DraftGraphProps {
|
||||
onRuntimeNodeClick?: (runtimeNodeId: string) => void;
|
||||
/** True while the queen is building the agent from the draft. */
|
||||
building?: boolean;
|
||||
/** Message to show with a spinner while loading/designing. Null = no spinner. */
|
||||
loadingMessage?: string | null;
|
||||
/** Called when the user clicks Run. */
|
||||
onRun?: () => void;
|
||||
/** Called when the user clicks Pause. */
|
||||
@@ -103,13 +119,6 @@ function formatNodeId(id: string): string {
|
||||
return id.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
/** Return the bounding-rect corner radius for a given flowchart shape. */
|
||||
/**
|
||||
* Render an ISO 5807 flowchart shape as an SVG element.
|
||||
@@ -142,13 +151,9 @@ function FlowchartShape({
|
||||
case "rectangle":
|
||||
return <rect x={x} y={y} width={w} height={h} rx={4} {...common} />;
|
||||
|
||||
case "rounded_rect":
|
||||
return <rect x={x} y={y} width={w} height={h} rx={12} {...common} />;
|
||||
|
||||
case "diamond": {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
// Keep diamond within bounding box
|
||||
return (
|
||||
<polygon
|
||||
points={`${cx},${y} ${x + w},${cy} ${cx},${y + h} ${x},${cy}`}
|
||||
@@ -172,18 +177,6 @@ function FlowchartShape({
|
||||
return <path d={d} {...common} />;
|
||||
}
|
||||
|
||||
case "multi_document": {
|
||||
const off = 3;
|
||||
const d = `M ${x} ${y + 4 + off} Q ${x} ${y + off}, ${x + 8} ${y + off} L ${x + w - 8 - off} ${y + off} Q ${x + w - off} ${y + off}, ${x + w - off} ${y + 4 + off} L ${x + w - off} ${y + h - 8} C ${x + (w - off) * 0.75} ${y + h + 2}, ${x + (w - off) * 0.25} ${y + h - 10}, ${x} ${y + h - 4} Z`;
|
||||
return (
|
||||
<g>
|
||||
<rect x={x + off * 2} y={y} width={w - off * 2} height={h - off} rx={4} fill={fill} stroke={stroke} strokeWidth={1.2} opacity={0.4} />
|
||||
<rect x={x + off} y={y + off / 2} width={w - off} height={h - off} rx={4} fill={fill} stroke={stroke} strokeWidth={1.2} opacity={0.6} />
|
||||
<path d={d} {...common} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
case "subroutine": {
|
||||
const inset = 7;
|
||||
return (
|
||||
@@ -205,34 +198,6 @@ function FlowchartShape({
|
||||
);
|
||||
}
|
||||
|
||||
case "manual_input":
|
||||
return (
|
||||
<polygon
|
||||
points={`${x},${y + 10} ${x + w},${y} ${x + w},${y + h} ${x},${y + h}`}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
|
||||
case "trapezoid": {
|
||||
const inset = 12;
|
||||
return (
|
||||
<polygon
|
||||
points={`${x},${y} ${x + w},${y} ${x + w - inset},${y + h} ${x + inset},${y + h}`}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "delay": {
|
||||
const d = `M ${x} ${y + 4} Q ${x} ${y}, ${x + 4} ${y} L ${x + w * 0.65} ${y} A ${w * 0.35} ${h / 2} 0 0 1 ${x + w * 0.65} ${y + h} L ${x + 4} ${y + h} Q ${x} ${y + h}, ${x} ${y + h - 4} Z`;
|
||||
return <path d={d} {...common} />;
|
||||
}
|
||||
|
||||
case "display": {
|
||||
const d = `M ${x + 16} ${y} L ${x + w * 0.65} ${y} A ${w * 0.35} ${h / 2} 0 0 1 ${x + w * 0.65} ${y + h} L ${x + 16} ${y + h} L ${x} ${y + h / 2} Z`;
|
||||
return <path d={d} {...common} />;
|
||||
}
|
||||
|
||||
case "cylinder": {
|
||||
const ry = 7;
|
||||
return (
|
||||
@@ -247,88 +212,6 @@ function FlowchartShape({
|
||||
);
|
||||
}
|
||||
|
||||
case "stored_data": {
|
||||
const d = `M ${x + 14} ${y} L ${x + w} ${y} A 10 ${h / 2} 0 0 0 ${x + w} ${y + h} L ${x + 14} ${y + h} A 10 ${h / 2} 0 0 1 ${x + 14} ${y} Z`;
|
||||
return <path d={d} {...common} />;
|
||||
}
|
||||
|
||||
case "internal_storage":
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={w} height={h} rx={4} {...common} />
|
||||
<line x1={x + 10} y1={y} x2={x + 10} y2={y + h} stroke={stroke} strokeWidth={0.8} opacity={0.5} />
|
||||
<line x1={x} y1={y + 10} x2={x + w} y2={y + 10} stroke={stroke} strokeWidth={0.8} opacity={0.5} />
|
||||
</g>
|
||||
);
|
||||
|
||||
case "circle": {
|
||||
const r = Math.min(w, h) / 2 - 2;
|
||||
return <circle cx={x + w / 2} cy={y + h / 2} r={r} {...common} />;
|
||||
}
|
||||
|
||||
case "pentagon":
|
||||
return (
|
||||
<polygon
|
||||
points={`${x},${y} ${x + w},${y} ${x + w},${y + h * 0.6} ${x + w / 2},${y + h} ${x},${y + h * 0.6}`}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
|
||||
case "triangle_inv":
|
||||
return (
|
||||
<polygon
|
||||
points={`${x},${y} ${x + w},${y} ${x + w / 2},${y + h}`}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
|
||||
case "triangle":
|
||||
return (
|
||||
<polygon
|
||||
points={`${x + w / 2},${y} ${x + w},${y + h} ${x},${y + h}`}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
|
||||
case "hourglass":
|
||||
return (
|
||||
<polygon
|
||||
points={`${x},${y} ${x + w},${y} ${x + w / 2},${y + h / 2} ${x + w},${y + h} ${x},${y + h} ${x + w / 2},${y + h / 2}`}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
|
||||
case "circle_cross": {
|
||||
const r = Math.min(w, h) / 2 - 2;
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={r} {...common} />
|
||||
<line x1={cx - r * 0.7} y1={cy - r * 0.7} x2={cx + r * 0.7} y2={cy + r * 0.7} stroke={stroke} strokeWidth={1} />
|
||||
<line x1={cx + r * 0.7} y1={cy - r * 0.7} x2={cx - r * 0.7} y2={cy + r * 0.7} stroke={stroke} strokeWidth={1} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
case "circle_bar": {
|
||||
const r = Math.min(w, h) / 2 - 2;
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={r} {...common} />
|
||||
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} stroke={stroke} strokeWidth={1} />
|
||||
<line x1={cx - r} y1={cy} x2={cx + r} y2={cy} stroke={stroke} strokeWidth={1} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
case "flag": {
|
||||
const d = `M ${x} ${y} L ${x + w} ${y} L ${x + w - 8} ${y + h / 2} L ${x + w} ${y + h} L ${x} ${y + h} Z`;
|
||||
return <path d={d} {...common} />;
|
||||
}
|
||||
|
||||
default:
|
||||
return <rect x={x} y={y} width={w} height={h} rx={8} {...common} />;
|
||||
}
|
||||
@@ -355,13 +238,51 @@ function Tooltip({ node, style }: { node: DraftNode; style: React.CSSProperties
|
||||
);
|
||||
}
|
||||
|
||||
export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNodes, onRuntimeNodeClick, building, onRun, onPause, runState = "idle" }: DraftGraphProps) {
|
||||
export default function DraftGraph({ draft, originalDraft, onNodeClick, flowchartMap, runtimeNodes, onRuntimeNodeClick, building, loadingMessage, onRun, onPause, runState = "idle" }: DraftGraphProps) {
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const runBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [containerW, setContainerW] = useState(484);
|
||||
const chrome = useDraftChromeColors();
|
||||
const triggerColors = useTriggerColors();
|
||||
|
||||
// Extract trigger nodes from runtimeNodes
|
||||
const triggerNodes = useMemo(
|
||||
() => (runtimeNodes ?? []).filter(n => n.nodeType === "trigger"),
|
||||
[runtimeNodes],
|
||||
);
|
||||
|
||||
// ── Entrance animation — fires when originalDraft becomes a new non-null value ──
|
||||
// This covers: agent loaded, build finished, queen modifies flowchart.
|
||||
// Tab switches remount via React key={activeWorker}, resetting all refs.
|
||||
const prevOriginalDraft = useRef<DraftGraphData | null>(null);
|
||||
const pendingAnimation = useRef(false);
|
||||
const [entrancePhase, setEntrancePhase] = useState<"idle" | "hidden" | "visible">("idle");
|
||||
|
||||
const nodes = draft?.nodes ?? [];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const prev = prevOriginalDraft.current;
|
||||
prevOriginalDraft.current = originalDraft ?? null;
|
||||
|
||||
// Detect a new non-null originalDraft (object identity — each API/SSE response is a fresh object)
|
||||
if (originalDraft && originalDraft !== prev) {
|
||||
pendingAnimation.current = true;
|
||||
}
|
||||
|
||||
// Fire when we have a pending animation, nodes are ready, and not mid-build
|
||||
if (pendingAnimation.current && nodes.length > 0 && !building) {
|
||||
pendingAnimation.current = false;
|
||||
setEntrancePhase("hidden");
|
||||
let raf1 = 0, raf2 = 0;
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setEntrancePhase("visible"));
|
||||
});
|
||||
const t = setTimeout(() => setEntrancePhase("idle"), nodes.length * 120 + 1000);
|
||||
return () => { clearTimeout(t); cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); };
|
||||
}
|
||||
}, [originalDraft, nodes.length, building]);
|
||||
|
||||
// Shift-to-pin tooltip
|
||||
const shiftHeld = useRef(false);
|
||||
@@ -463,7 +384,7 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
|
||||
const hasStatusOverlay = Object.keys(nodeStatuses).length > 0;
|
||||
|
||||
const { nodes, edges } = draft;
|
||||
const edges = draft?.edges ?? [];
|
||||
|
||||
const idxMap = useMemo(
|
||||
() => Object.fromEntries(nodes.map((n, i) => [n.id, i])),
|
||||
@@ -536,6 +457,11 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
layerGroups.forEach((group) => {
|
||||
maxCols = Math.max(maxCols, group.length);
|
||||
});
|
||||
// Ensure maxCols accommodates any parent's children fan-out
|
||||
// (prevents fan-out scaling from collapsing to zero)
|
||||
children.forEach((kids) => {
|
||||
maxCols = Math.max(maxCols, kids.length);
|
||||
});
|
||||
|
||||
// Compute node width — keep back-edge overflow out of node sizing so nodes
|
||||
// get full width. The viewBox is expanded later to fit back-edge curves.
|
||||
@@ -641,6 +567,17 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
}
|
||||
}
|
||||
|
||||
// Post-process: enforce minimum spacing within each layer
|
||||
for (const [, group] of layerGroups) {
|
||||
if (group.length <= 1) continue;
|
||||
const sorted = [...group].sort((a, b) => colPos[a] - colPos[b]);
|
||||
for (let j = 1; j < sorted.length; j++) {
|
||||
if (colPos[sorted[j]] < colPos[sorted[j - 1]] + 1) {
|
||||
colPos[sorted[j]] = colPos[sorted[j - 1]] + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert fractional column positions to pixel X positions
|
||||
const colSpacing = nodeW + GAP_X;
|
||||
const usedMin = Math.min(...colPos);
|
||||
@@ -656,25 +593,6 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
return { layers, nodeW, firstColX, nodeXPositions, backEdgeOverflow, maxContentRight };
|
||||
}, [nodes, forwardEdges, backEdges.length, containerW, flowchartMap, idxMap]);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Draft
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<p className="text-xs text-muted-foreground/60 text-center italic">
|
||||
No draft graph yet.
|
||||
<br />
|
||||
Describe your workflow to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { layers, nodeW, nodeXPositions, backEdgeOverflow, maxContentRight } = layout;
|
||||
|
||||
const maxLayer = nodes.length > 0 ? Math.max(...layers) : 0;
|
||||
@@ -803,22 +721,27 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
return { nodeYOffset: offsets, totalExtraY: totalExtra, groupBoxMaxX: maxGroupX };
|
||||
}, [nodes, maxLayer, flowchartMap, idxMap, layers, nodeXPositions, nodeW]);
|
||||
|
||||
// When triggers are present, push the entire draft graph down to make room
|
||||
const triggerOffsetY = triggerNodes.length > 0
|
||||
? TRIGGER_H + TRIGGER_TEXT_Y + TRIGGER_TEXT_STEP + TRIGGER_CLEARANCE
|
||||
: 0;
|
||||
|
||||
const nodePos = (i: number) => ({
|
||||
x: nodeXPositions[i],
|
||||
y: TOP_Y + layers[i] * (NODE_H + GAP_Y) + nodeYOffset[i],
|
||||
y: TOP_Y + triggerOffsetY + layers[i] * (NODE_H + GAP_Y) + nodeYOffset[i],
|
||||
});
|
||||
|
||||
const svgHeight = TOP_Y + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y + totalExtraY + 16;
|
||||
const svgHeight = TOP_Y + triggerOffsetY + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y + totalExtraY + 16;
|
||||
|
||||
// Compute group areas for runtime node boundaries on the draft
|
||||
const groupAreas = useMemo(() => {
|
||||
if (!flowchartMap || !runtimeNodes?.length) return [];
|
||||
if (!flowchartMap) return [];
|
||||
const groups: { runtimeId: string; label: string; draftIds: string[] }[] = [];
|
||||
for (const [runtimeId, draftIds] of Object.entries(flowchartMap)) {
|
||||
groups.push({ runtimeId, label: formatNodeId(runtimeId), draftIds });
|
||||
}
|
||||
return groups;
|
||||
}, [flowchartMap, runtimeNodes]);
|
||||
}, [flowchartMap]);
|
||||
|
||||
// Legend
|
||||
const usedTypes = (() => {
|
||||
@@ -856,12 +779,27 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
? `M ${startX} ${y1} L ${toCenterX} ${y2}`
|
||||
: `M ${startX} ${y1} L ${startX} ${midY} L ${toCenterX} ${midY} L ${toCenterX} ${y2}`;
|
||||
|
||||
// Edge draw-in animation (stroke-dashoffset)
|
||||
const isAnimating = entrancePhase !== "idle";
|
||||
const pathLength = Math.abs(y2 - y1) + Math.abs(startX - toCenterX) + 1;
|
||||
const edgeDelay = 200 + i * 80;
|
||||
const edgeStyle: React.CSSProperties | undefined = isAnimating ? {
|
||||
strokeDasharray: pathLength,
|
||||
strokeDashoffset: entrancePhase === "hidden" ? pathLength : 0,
|
||||
transition: `stroke-dashoffset 400ms ease-in-out ${edgeDelay}ms`,
|
||||
} : undefined;
|
||||
const edgeEndStyle: React.CSSProperties | undefined = isAnimating ? {
|
||||
opacity: entrancePhase === "hidden" ? 0 : 1,
|
||||
transition: `opacity 100ms ease-out ${edgeDelay + 350}ms`,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<g key={`fwd-${i}`}>
|
||||
<path d={d} fill="none" stroke={chrome.edge} strokeWidth={1.2} />
|
||||
<path d={d} fill="none" stroke={chrome.edge} strokeWidth={1.2} style={edgeStyle} />
|
||||
<polygon
|
||||
points={`${toCenterX - 3},${y2 - 5} ${toCenterX + 3},${y2 - 5} ${toCenterX},${y2 - 1}`}
|
||||
fill={chrome.edgeArrow}
|
||||
style={edgeEndStyle}
|
||||
/>
|
||||
{edge.label && (
|
||||
<text
|
||||
@@ -871,6 +809,7 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
fontSize={9}
|
||||
fontStyle="italic"
|
||||
textAnchor="middle"
|
||||
style={edgeEndStyle}
|
||||
>
|
||||
{truncateLabel(edge.label, 80, 9)}
|
||||
</text>
|
||||
@@ -893,12 +832,26 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
|
||||
const path = `M ${startX} ${startY} C ${startX + r} ${startY}, ${curveX} ${startY}, ${curveX} ${startY - r} L ${curveX} ${endY + r} C ${curveX} ${endY}, ${endX + r} ${endY}, ${endX + 5} ${endY}`;
|
||||
|
||||
// Back-edge draw-in animation (starts after forward edges)
|
||||
const isAnimating = entrancePhase !== "idle";
|
||||
const backPathLength = Math.abs(curveX - startX) + Math.abs(startY - endY) + Math.abs(curveX - endX) + 20;
|
||||
const backDelay = nodes.length * 120 + 300 + i * 80;
|
||||
const backEdgeStyle: React.CSSProperties | undefined = isAnimating ? {
|
||||
strokeDashoffset: entrancePhase === "hidden" ? backPathLength : 0,
|
||||
transition: `stroke-dashoffset 400ms ease-in-out ${backDelay}ms`,
|
||||
} : undefined;
|
||||
const backEndStyle: React.CSSProperties | undefined = isAnimating ? {
|
||||
opacity: entrancePhase === "hidden" ? 0 : 1,
|
||||
transition: `opacity 100ms ease-out ${backDelay + 350}ms`,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<g key={`back-${i}`}>
|
||||
<path d={path} fill="none" stroke={chrome.backEdge} strokeWidth={1.2} strokeDasharray="4 3" />
|
||||
<path d={path} fill="none" stroke={chrome.backEdge} strokeWidth={1.2} strokeDasharray={isAnimating ? backPathLength : "4 3"} style={backEdgeStyle} />
|
||||
<polygon
|
||||
points={`${endX + 5},${endY - 2.5} ${endX + 5},${endY + 2.5} ${endX},${endY}`}
|
||||
fill={chrome.edge}
|
||||
style={backEndStyle}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
@@ -911,6 +864,131 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
pending: "",
|
||||
};
|
||||
|
||||
// ── Trigger node rendering ──
|
||||
|
||||
const triggerW = Math.min(nodeW, 180);
|
||||
|
||||
// Shared trigger pill X position (used by both node and edge renderers)
|
||||
const triggerPillX = (idx: number) => {
|
||||
const totalW = triggerNodes.length * triggerW + (triggerNodes.length - 1) * TRIGGER_PILL_GAP_X;
|
||||
return (containerW - totalW) / 2 + idx * (triggerW + TRIGGER_PILL_GAP_X);
|
||||
};
|
||||
|
||||
const renderTriggerNode = (node: GraphNode, triggerIdx: number) => {
|
||||
const icon = TRIGGER_ICONS[node.triggerType || ""] || "\u26A1";
|
||||
const isActive = node.status === "running" || node.status === "complete";
|
||||
const colors = isActive ? ACTIVE_TRIGGER_COLORS : triggerColors;
|
||||
const nextFireIn = node.triggerConfig?.next_fire_in as number | undefined;
|
||||
|
||||
const tx = triggerPillX(triggerIdx);
|
||||
const ty = TOP_Y;
|
||||
|
||||
const fontSize = triggerW < 140 ? 10.5 : 11.5;
|
||||
const displayLabel = truncateLabel(node.label, triggerW - TRIGGER_LABEL_INSET, fontSize);
|
||||
|
||||
// Countdown
|
||||
let countdownLabel: string | null = null;
|
||||
if (isActive && nextFireIn != null && nextFireIn > 0) {
|
||||
const h = Math.floor(nextFireIn / 3600);
|
||||
const m = Math.floor((nextFireIn % 3600) / 60);
|
||||
const s = Math.floor(nextFireIn % 60);
|
||||
countdownLabel = h > 0
|
||||
? `next in ${h}h ${String(m).padStart(2, "0")}m`
|
||||
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
const statusLabel = isActive ? "active" : "inactive";
|
||||
const statusColor = isActive ? "hsl(140,40%,50%)" : "hsl(210,20%,40%)";
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
onClick={() => onRuntimeNodeClick?.(node.id)}
|
||||
style={{ cursor: onRuntimeNodeClick ? "pointer" : "default" }}
|
||||
>
|
||||
<title>{node.label}</title>
|
||||
{/* Pill-shaped background */}
|
||||
<rect
|
||||
x={tx} y={ty}
|
||||
width={triggerW} height={TRIGGER_H}
|
||||
rx={TRIGGER_H / 2}
|
||||
fill={colors.bg}
|
||||
stroke={colors.border}
|
||||
strokeWidth={isActive ? 1.5 : 1}
|
||||
strokeDasharray={isActive ? undefined : "4 2"}
|
||||
/>
|
||||
{/* Icon */}
|
||||
<text
|
||||
x={tx + TRIGGER_ICON_X} y={ty + TRIGGER_H / 2}
|
||||
fill={colors.icon} fontSize={13}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
>
|
||||
{icon}
|
||||
</text>
|
||||
{/* Label */}
|
||||
<text
|
||||
x={tx + TRIGGER_LABEL_X} y={ty + TRIGGER_H / 2}
|
||||
fill={colors.text}
|
||||
fontSize={fontSize}
|
||||
fontWeight={500}
|
||||
dominantBaseline="middle"
|
||||
letterSpacing="0.01em"
|
||||
>
|
||||
{displayLabel}
|
||||
</text>
|
||||
{/* Countdown */}
|
||||
{countdownLabel && (
|
||||
<text
|
||||
x={tx + triggerW / 2} y={ty + TRIGGER_H + TRIGGER_TEXT_Y}
|
||||
fill={colors.text} fontSize={9}
|
||||
textAnchor="middle" fontStyle="italic" opacity={0.7}
|
||||
>
|
||||
{countdownLabel}
|
||||
</text>
|
||||
)}
|
||||
{/* Status */}
|
||||
<text
|
||||
x={tx + triggerW / 2} y={ty + TRIGGER_H + (countdownLabel ? TRIGGER_TEXT_Y + TRIGGER_TEXT_STEP : TRIGGER_TEXT_Y)}
|
||||
fill={statusColor} fontSize={8.5}
|
||||
textAnchor="middle" opacity={0.8}
|
||||
>
|
||||
{statusLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTriggerEdge = (triggerIdx: number) => {
|
||||
if (nodes.length === 0) return null;
|
||||
const triggerNode = triggerNodes[triggerIdx];
|
||||
const runtimeTargetId = triggerNode?.next?.[0];
|
||||
const targetDraftId = runtimeTargetId
|
||||
? flowchartMap?.[runtimeTargetId]?.[0] ?? runtimeTargetId
|
||||
: draft?.entry_node;
|
||||
const targetIdx = targetDraftId ? idxMap[targetDraftId] ?? 0 : 0;
|
||||
const targetPos = nodePos(targetIdx);
|
||||
const targetX = targetPos.x + nodeW / 2;
|
||||
const targetY = targetPos.y;
|
||||
|
||||
const tx = triggerPillX(triggerIdx) + triggerW / 2;
|
||||
const ty = TOP_Y + TRIGGER_H + TRIGGER_TEXT_Y + TRIGGER_TEXT_STEP + 4;
|
||||
|
||||
const midY = (ty + targetY) / 2;
|
||||
const d = Math.abs(tx - targetX) < 2
|
||||
? `M ${tx} ${ty} L ${targetX} ${targetY}`
|
||||
: `M ${tx} ${ty} L ${tx} ${midY} L ${targetX} ${midY} L ${targetX} ${targetY}`;
|
||||
|
||||
return (
|
||||
<g key={`trigger-edge-${triggerIdx}`}>
|
||||
<path d={d} fill="none" stroke={chrome.edge} strokeWidth={1.2} strokeDasharray="4 3" />
|
||||
<polygon
|
||||
points={`${targetX - 3},${targetY - 5} ${targetX + 3},${targetY - 5} ${targetX},${targetY - 1}`}
|
||||
fill={chrome.edgeArrow}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNode = (node: DraftNode, i: number) => {
|
||||
const pos = nodePos(i);
|
||||
const isHovered = hoveredNode === node.id;
|
||||
@@ -942,7 +1020,13 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
if (rect) setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
}}
|
||||
onMouseLeave={() => { if (!shiftHeld.current) { setHoveredNode(null); setMousePos(null); } }}
|
||||
style={{ cursor: "pointer" }}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
...(entrancePhase !== "idle" ? {
|
||||
opacity: entrancePhase === "hidden" ? 0 : 1,
|
||||
transition: `opacity 300ms ease-out ${i * 120}ms`,
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
|
||||
<FlowchartShape
|
||||
@@ -982,6 +1066,30 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
);
|
||||
};
|
||||
|
||||
if (!draft || nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 pt-3 pb-1.5 flex items-center gap-2">
|
||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">Draft</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
||||
{loadingMessage ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground/50">{loadingMessage}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/60 text-center italic">
|
||||
No draft graph yet.
|
||||
<br />
|
||||
Describe your workflow to get started.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
@@ -995,6 +1103,11 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
building
|
||||
</span>
|
||||
) : loadingMessage ? (
|
||||
<span className="text-[9px] font-mono font-medium rounded px-1 py-0.5 leading-none border text-amber-500/60 border-amber-500/20 flex items-center gap-1">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
updating
|
||||
</span>
|
||||
) : (
|
||||
<span className={`text-[9px] font-mono font-medium rounded px-1 py-0.5 leading-none border ${hasStatusOverlay ? "text-emerald-500/60 border-emerald-500/20" : "text-amber-500/60 border-amber-500/20"}`}>
|
||||
{hasStatusOverlay ? "live" : "planning"}
|
||||
@@ -1014,12 +1127,16 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className={`w-full h-full${building ? " opacity-30" : ""}`}
|
||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
opacity: building || loadingMessage ? 0.3 : 1,
|
||||
transition: building || loadingMessage ? "none" : "opacity 300ms ease-out",
|
||||
cursor: dragging ? "grabbing" : "grab",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${Math.max((maxContentRight ?? 0), groupBoxMaxX) + (backEdgeOverflow ?? 0)} ${totalH}`}
|
||||
viewBox={`0 0 ${Math.max((maxContentRight ?? 0), groupBoxMaxX, triggerNodes.length > 0 ? triggerPillX(triggerNodes.length - 1) + triggerW : 0) + (backEdgeOverflow ?? 0)} ${totalH}`}
|
||||
preserveAspectRatio="xMidYMin meet"
|
||||
className="select-none"
|
||||
style={{
|
||||
@@ -1103,6 +1220,11 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Trigger edges (dashed lines from trigger pills to first draft node) */}
|
||||
{triggerNodes.map((_, i) => renderTriggerEdge(i))}
|
||||
{/* Trigger pill nodes */}
|
||||
{triggerNodes.map((tn, i) => renderTriggerNode(tn, i))}
|
||||
|
||||
{forwardEdges.map((e, i) => renderEdge(e, i))}
|
||||
{backEdges.map((e, i) => renderBackEdge(e, i))}
|
||||
{nodes.map((n, i) => renderNode(n, i))}
|
||||
@@ -1141,6 +1263,15 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!building && loadingMessage && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground/50">{loadingMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-card/80 backdrop-blur-sm border border-border/40 rounded-lg p-0.5 shadow-sm">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, Cpu, Zap, Clock, RotateCcw, CheckCircle2, AlertCircle, Loader2, ChevronDown, ChevronRight, Copy, Check, Terminal, Wrench, BookOpen, GitBranch, Bot } from "lucide-react";
|
||||
import type { GraphNode, NodeStatus } from "./AgentGraph";
|
||||
import type { GraphNode, NodeStatus } from "./graph-types";
|
||||
import type { NodeSpec, ToolInfo, NodeCriteria } from "../api/types";
|
||||
import { graphsApi } from "../api/graphs";
|
||||
import { logsApi } from "../api/logs";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { memo, useState } from "react";
|
||||
import { Play, Pause, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import type { RunButtonProps } from "./graph-types";
|
||||
|
||||
export const RunButton = memo(function RunButton({ runState, disabled, onRun, onPause, btnRef }: RunButtonProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const showPause = runState === "running" && hovered;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={runState === "running" ? onPause : onRun}
|
||||
disabled={runState === "deploying" || disabled}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all duration-200 ${
|
||||
showPause
|
||||
? "bg-amber-500/15 text-amber-400 border border-amber-500/40 hover:bg-amber-500/25 active:scale-95 cursor-pointer"
|
||||
: runState === "running"
|
||||
? "bg-green-500/15 text-green-400 border border-green-500/30 cursor-pointer"
|
||||
: runState === "deploying"
|
||||
? "bg-primary/10 text-primary border border-primary/20 cursor-default"
|
||||
: disabled
|
||||
? "bg-muted/30 text-muted-foreground/40 border border-border/20 cursor-not-allowed"
|
||||
: "bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 hover:border-primary/40 active:scale-95"
|
||||
}`}
|
||||
>
|
||||
{runState === "deploying" ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : showPause ? (
|
||||
<Pause className="w-3 h-3 fill-current" />
|
||||
) : runState === "running" ? (
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
) : (
|
||||
<Play className="w-3 h-3 fill-current" />
|
||||
)}
|
||||
{runState === "deploying" ? "Deploying\u2026" : showPause ? "Pause" : runState === "running" ? "Running" : "Run"}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export type NodeStatus = "running" | "complete" | "pending" | "error" | "looping";
|
||||
|
||||
export type NodeType = "execution" | "trigger";
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
status: NodeStatus;
|
||||
nodeType?: NodeType;
|
||||
triggerType?: string;
|
||||
triggerConfig?: Record<string, unknown>;
|
||||
next?: string[];
|
||||
backEdges?: string[];
|
||||
iterations?: number;
|
||||
maxIterations?: number;
|
||||
statusLabel?: string;
|
||||
edgeLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type RunState = "idle" | "deploying" | "running";
|
||||
|
||||
export interface RunButtonProps {
|
||||
runState: RunState;
|
||||
disabled: boolean;
|
||||
onRun: () => void;
|
||||
onPause: () => void;
|
||||
btnRef: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
@@ -196,6 +196,102 @@ describe("sseEventToChatMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("different inner_turn values produce different message IDs", () => {
|
||||
const e1 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "first response", iteration: 0, inner_turn: 0 },
|
||||
});
|
||||
const e2 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "after tool call", iteration: 0, inner_turn: 1 },
|
||||
});
|
||||
const r1 = sseEventToChatMessage(e1, "t");
|
||||
const r2 = sseEventToChatMessage(e2, "t");
|
||||
expect(r1!.id).not.toBe(r2!.id);
|
||||
});
|
||||
|
||||
it("same inner_turn produces same ID (streaming upsert within one LLM call)", () => {
|
||||
const e1 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "partial", iteration: 0, inner_turn: 1 },
|
||||
});
|
||||
const e2 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "partial response", iteration: 0, inner_turn: 1 },
|
||||
});
|
||||
expect(sseEventToChatMessage(e1, "t")!.id).toBe(
|
||||
sseEventToChatMessage(e2, "t")!.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("absent inner_turn produces same ID as inner_turn=0 (backward compat)", () => {
|
||||
const withField = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "hello", iteration: 2, inner_turn: 0 },
|
||||
});
|
||||
const withoutField = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "hello", iteration: 2 },
|
||||
});
|
||||
expect(sseEventToChatMessage(withField, "t")!.id).toBe(
|
||||
sseEventToChatMessage(withoutField, "t")!.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("inner_turn=0 produces no suffix (matches old ID format)", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "hello", iteration: 3, inner_turn: 0 },
|
||||
});
|
||||
const result = sseEventToChatMessage(event, "t");
|
||||
expect(result!.id).toBe("stream-exec-1-3-queen");
|
||||
});
|
||||
|
||||
it("inner_turn>0 adds -t suffix to ID", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "hello", iteration: 3, inner_turn: 2 },
|
||||
});
|
||||
const result = sseEventToChatMessage(event, "t");
|
||||
expect(result!.id).toBe("stream-exec-1-3-t2-queen");
|
||||
});
|
||||
|
||||
it("llm_text_delta also uses inner_turn for distinct IDs", () => {
|
||||
const e1 = makeEvent({
|
||||
type: "llm_text_delta",
|
||||
node_id: "research",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "first", inner_turn: 0 },
|
||||
});
|
||||
const e2 = makeEvent({
|
||||
type: "llm_text_delta",
|
||||
node_id: "research",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "second", inner_turn: 1 },
|
||||
});
|
||||
const r1 = sseEventToChatMessage(e1, "t");
|
||||
const r2 = sseEventToChatMessage(e2, "t");
|
||||
expect(r1!.id).not.toBe(r2!.id);
|
||||
expect(r1!.id).toBe("stream-exec-1-research");
|
||||
expect(r2!.id).toBe("stream-exec-1-t1-research");
|
||||
});
|
||||
|
||||
it("uses timestamp fallback when both turnId and execution_id are null", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
|
||||
@@ -56,10 +56,15 @@ export function sseEventToChatMessage(
|
||||
const iterTid = iter != null ? String(iter) : tid;
|
||||
const iterIdKey = eid && iterTid ? `${eid}-${iterTid}` : eid || iterTid || `t-${Date.now()}`;
|
||||
|
||||
// Distinguish multiple LLM calls within the same iteration (inner tool loop).
|
||||
// inner_turn=0 (or absent) produces no suffix for backward compat.
|
||||
const innerTurn = event.data?.inner_turn as number | undefined;
|
||||
const innerSuffix = innerTurn != null && innerTurn > 0 ? `-t${innerTurn}` : "";
|
||||
|
||||
const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
|
||||
if (!snapshot) return null;
|
||||
return {
|
||||
id: `stream-${iterIdKey}-${event.node_id}`,
|
||||
id: `stream-${iterIdKey}${innerSuffix}-${event.node_id}`,
|
||||
agent: agentDisplayName || event.node_id || "Agent",
|
||||
agentColor: "",
|
||||
content: snapshot,
|
||||
@@ -91,10 +96,13 @@ export function sseEventToChatMessage(
|
||||
}
|
||||
|
||||
case "llm_text_delta": {
|
||||
const llmInnerTurn = event.data?.inner_turn as number | undefined;
|
||||
const llmInnerSuffix = llmInnerTurn != null && llmInnerTurn > 0 ? `-t${llmInnerTurn}` : "";
|
||||
|
||||
const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
|
||||
if (!snapshot) return null;
|
||||
return {
|
||||
id: `stream-${idKey}-${event.node_id}`,
|
||||
id: `stream-${idKey}${llmInnerSuffix}-${event.node_id}`,
|
||||
agent: event.node_id || "Agent",
|
||||
agentColor: "",
|
||||
content: snapshot,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { GraphTopology, NodeSpec } from "@/api/types";
|
||||
import type { GraphNode, NodeStatus } from "@/components/AgentGraph";
|
||||
import type { GraphNode, NodeStatus } from "@/components/graph-types";
|
||||
|
||||
/**
|
||||
* Convert a backend GraphTopology (nodes + edges + entry_node) into
|
||||
* the GraphNode[] shape that AgentGraph renders.
|
||||
* the GraphNode[] shape that DraftGraph renders.
|
||||
*
|
||||
* Four jobs:
|
||||
* 1. Synthesize trigger nodes from non-manual entry_points
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// ── Shared graph utilities ──
|
||||
// Common helpers used by both AgentGraph and DraftGraph.
|
||||
// AgentGraph still has its own copies for now (separate cleanup PR).
|
||||
|
||||
/** Read a CSS custom property value (space-separated HSL components). */
|
||||
export function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
/** Truncate label to fit within `availablePx` at the given fontSize. */
|
||||
export 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";
|
||||
}
|
||||
|
||||
// ── Trigger styling ──
|
||||
|
||||
export type TriggerColorSet = { bg: string; border: string; text: string; icon: string };
|
||||
|
||||
export function buildTriggerColors(): TriggerColorSet {
|
||||
const bg = cssVar("--trigger-bg") || "210 25% 14%";
|
||||
const border = cssVar("--trigger-border") || "210 30% 30%";
|
||||
const text = cssVar("--trigger-text") || "210 30% 65%";
|
||||
const icon = cssVar("--trigger-icon") || "210 40% 55%";
|
||||
return {
|
||||
bg: `hsl(${bg})`,
|
||||
border: `hsl(${border})`,
|
||||
text: `hsl(${text})`,
|
||||
icon: `hsl(${icon})`,
|
||||
};
|
||||
}
|
||||
|
||||
export const ACTIVE_TRIGGER_COLORS: TriggerColorSet = {
|
||||
bg: "hsl(210,30%,18%)",
|
||||
border: "hsl(210,50%,50%)",
|
||||
text: "hsl(210,40%,75%)",
|
||||
icon: "hsl(210,60%,65%)",
|
||||
};
|
||||
|
||||
export const TRIGGER_ICONS: Record<string, string> = {
|
||||
webhook: "\u26A1", // lightning bolt
|
||||
timer: "\u23F1", // stopwatch
|
||||
api: "\u2192", // right arrow
|
||||
event: "\u223F", // sine wave
|
||||
};
|
||||
|
||||
/** Format a cron expression into a human-readable schedule label. */
|
||||
export function cronToLabel(cron: string): string {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return cron;
|
||||
const [min, hour, dom, mon, dow] = parts;
|
||||
|
||||
// */N * * * * -> "Every Nm"
|
||||
if (min.startsWith("*/") && hour === "*" && dom === "*" && mon === "*" && dow === "*") {
|
||||
return `Every ${min.slice(2)}m`;
|
||||
}
|
||||
// 0 */N * * * -> "Every Nh"
|
||||
if (min === "0" && hour.startsWith("*/") && dom === "*" && mon === "*" && dow === "*") {
|
||||
return `Every ${hour.slice(2)}h`;
|
||||
}
|
||||
// 0 H * * * -> "Daily at Ham/pm"
|
||||
if (dom === "*" && mon === "*" && dow === "*" && !min.includes("*") && !hour.includes("*")) {
|
||||
const h = parseInt(hour, 10);
|
||||
const m = parseInt(min, 10);
|
||||
const suffix = h >= 12 ? "PM" : "AM";
|
||||
const h12 = h % 12 || 12;
|
||||
return m === 0 ? `Daily at ${h12}${suffix}` : `Daily at ${h12}:${String(m).padStart(2, "0")}${suffix}`;
|
||||
}
|
||||
return cron;
|
||||
}
|
||||
|
||||
/** Theme-reactive hook for inactive trigger colors. */
|
||||
export function useTriggerColors(): TriggerColorSet {
|
||||
const [colors, setColors] = useState<TriggerColorSet>(buildTriggerColors);
|
||||
|
||||
useEffect(() => {
|
||||
const rebuild = () => setColors(buildTriggerColors());
|
||||
const obs = new MutationObserver(rebuild);
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
return colors;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { ChatMessage } from "@/components/ChatPanel";
|
||||
import type { GraphNode } from "@/components/AgentGraph";
|
||||
import type { GraphNode } from "@/components/graph-types";
|
||||
|
||||
export const TAB_STORAGE_KEY = "hive:workspace-tabs";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { Plus, KeyRound, Sparkles, Layers, ChevronLeft, Bot, Loader2, WifiOff, X } from "lucide-react";
|
||||
import AgentGraph, { type GraphNode, type NodeStatus } from "@/components/AgentGraph";
|
||||
import type { GraphNode, NodeStatus } from "@/components/graph-types";
|
||||
import DraftGraph from "@/components/DraftGraph";
|
||||
import ChatPanel, { type ChatMessage } from "@/components/ChatPanel";
|
||||
import TopBar from "@/components/TopBar";
|
||||
@@ -17,6 +17,7 @@ import { useMultiSSE } from "@/hooks/use-sse";
|
||||
import type { LiveSession, AgentEvent, DiscoverEntry, NodeSpec, DraftGraph as DraftGraphData } from "@/api/types";
|
||||
import { sseEventToChatMessage, formatAgentDisplayName } from "@/lib/chat-helpers";
|
||||
import { topologyToGraphNodes } from "@/lib/graph-converter";
|
||||
import { cronToLabel } from "@/lib/graphUtils";
|
||||
import { ApiError } from "@/api/client";
|
||||
|
||||
const makeId = () => Math.random().toString(36).slice(2, 9);
|
||||
@@ -113,7 +114,13 @@ function NewTabPopover({ open, onClose, anchorRef, discoverAgents, onFromScratch
|
||||
useEffect(() => {
|
||||
if (open && anchorRef.current) {
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
setPos({ top: rect.bottom + 4, left: rect.left });
|
||||
const POPUP_WIDTH = 240; // w-60 = 15rem = 240px
|
||||
const overflows = rect.left + POPUP_WIDTH > window.innerWidth - 8;
|
||||
console.log("Anchor rect:", rect, "Overflows:", overflows);
|
||||
setPos({
|
||||
top: rect.bottom + 4,
|
||||
left: overflows ? rect.right - POPUP_WIDTH : rect.left,
|
||||
});
|
||||
}
|
||||
}, [open, anchorRef]);
|
||||
|
||||
@@ -321,6 +328,8 @@ interface AgentBackendState {
|
||||
workerIsTyping: boolean;
|
||||
llmSnapshots: Record<string, string>;
|
||||
activeToolCalls: Record<string, { name: string; done: boolean; streamId: string }>;
|
||||
/** True while save_agent_draft tool is running (between tool_call_started and draft_graph_updated) */
|
||||
designingDraft: boolean;
|
||||
/** Agent folder path — set after scaffolding, used for credential queries */
|
||||
agentPath: string | null;
|
||||
/** Structured question text from ask_user with options */
|
||||
@@ -347,6 +356,7 @@ function defaultAgentState(): AgentBackendState {
|
||||
workerInputMessageId: null,
|
||||
queenBuilding: false,
|
||||
queenPhase: "planning",
|
||||
designingDraft: false,
|
||||
draftGraph: null,
|
||||
originalDraft: null,
|
||||
flowchartMap: null,
|
||||
@@ -548,9 +558,46 @@ export default function Workspace() {
|
||||
const [dismissedBanner, setDismissedBanner] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [triggerTaskDraft, setTriggerTaskDraft] = useState("");
|
||||
const [triggerCronDraft, setTriggerCronDraft] = useState("");
|
||||
const [triggerTaskSaving, setTriggerTaskSaving] = useState(false);
|
||||
const [triggerScheduleSaving, setTriggerScheduleSaving] = useState(false);
|
||||
const [triggerCronSaved, setTriggerCronSaved] = useState(false);
|
||||
const [triggerTaskSaved, setTriggerTaskSaved] = useState(false);
|
||||
const [newTabOpen, setNewTabOpen] = useState(false);
|
||||
const newTabBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [graphPanelPct, setGraphPanelPct] = useState(30);
|
||||
const savedGraphPanelPct = useRef(30);
|
||||
const resizing = useRef(false);
|
||||
|
||||
// Drag-to-resize the graph panel
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!resizing.current) return;
|
||||
const pct = (e.clientX / window.innerWidth) * 100;
|
||||
setGraphPanelPct(Math.max(15, Math.min(50, pct)));
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
resizing.current = false;
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Shrink graph panel when node detail opens, restore when it closes
|
||||
const nodeIsSelected = selectedNode !== null;
|
||||
useEffect(() => {
|
||||
if (nodeIsSelected) {
|
||||
savedGraphPanelPct.current = graphPanelPct;
|
||||
setGraphPanelPct(prev => Math.min(prev, 30));
|
||||
} else {
|
||||
setGraphPanelPct(savedGraphPanelPct.current);
|
||||
}
|
||||
}, [nodeIsSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Ref mirror of sessionsByAgent so SSE callback can read current graph
|
||||
// state without adding sessionsByAgent to its dependency array.
|
||||
@@ -571,6 +618,9 @@ export default function Workspace() {
|
||||
// it was created in (avoids stale-closure when phase change and message
|
||||
// events arrive in the same React batch).
|
||||
const queenPhaseRef = useRef<Record<string, string>>({});
|
||||
// Timestamp when designingDraft was set — used to enforce minimum spinner duration.
|
||||
const designingDraftSinceRef = useRef<Record<string, number>>({});
|
||||
const designingDraftTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
// Synchronous ref to suppress the queen's auto-intro SSE messages
|
||||
// after a cold-restore (where we already restored the conversation from disk).
|
||||
@@ -1180,8 +1230,8 @@ export default function Workspace() {
|
||||
graphsApi.draftGraph(state.sessionId).then(({ draft }) => {
|
||||
if (draft) updateAgentState(agentType, { draftGraph: draft });
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
// Fetch flowchart map for non-planning phases (staging, running, building)
|
||||
} else if (state.queenPhase !== "building") {
|
||||
// Fetch flowchart map for non-building phases (staging, running)
|
||||
if (state.originalDraft) continue; // already have it
|
||||
if (fetchedFlowchartMapSessionsRef.current.has(state.sessionId)) continue;
|
||||
fetchedFlowchartMapSessionsRef.current.add(state.sessionId);
|
||||
@@ -1190,6 +1240,7 @@ export default function Workspace() {
|
||||
updateAgentState(agentType, {
|
||||
flowchartMap: map,
|
||||
originalDraft: original_draft,
|
||||
draftGraph: null,
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
@@ -1214,12 +1265,28 @@ export default function Workspace() {
|
||||
|
||||
const fireMap = new Map<string, number>();
|
||||
const taskMap = new Map<string, string>();
|
||||
const labelMap = new Map<string, string>();
|
||||
const targetMap = new Map<string, string>();
|
||||
for (const ep of triggerEps) {
|
||||
const nodeId = `__trigger_${ep.id}`;
|
||||
if (ep.next_fire_in != null) {
|
||||
fireMap.set(`__trigger_${ep.id}`, ep.next_fire_in);
|
||||
fireMap.set(nodeId, ep.next_fire_in);
|
||||
}
|
||||
if (ep.task != null) {
|
||||
taskMap.set(`__trigger_${ep.id}`, ep.task);
|
||||
taskMap.set(nodeId, ep.task);
|
||||
}
|
||||
const cron = ep.trigger_config?.cron as string | undefined;
|
||||
const interval = ep.trigger_config?.interval_minutes as number | undefined;
|
||||
const epLabel = cron
|
||||
? cronToLabel(cron)
|
||||
: interval
|
||||
? `Every ${interval >= 60 ? `${interval / 60}h` : `${interval}m`}`
|
||||
: ep.name || undefined;
|
||||
if (epLabel) {
|
||||
labelMap.set(nodeId, epLabel);
|
||||
}
|
||||
if (ep.entry_node) {
|
||||
targetMap.set(nodeId, ep.entry_node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1228,14 +1295,18 @@ export default function Workspace() {
|
||||
if (!ss?.length) return prev;
|
||||
const existingIds = new Set(ss[0].graphNodes.map(n => n.id));
|
||||
|
||||
// Update existing trigger nodes
|
||||
// Update existing trigger nodes (countdown, task, label, target)
|
||||
let updated = ss[0].graphNodes.map((n) => {
|
||||
if (n.nodeType !== "trigger") return n;
|
||||
const nfi = fireMap.get(n.id);
|
||||
const task = taskMap.get(n.id);
|
||||
if (nfi == null && task == null) return n;
|
||||
const label = labelMap.get(n.id);
|
||||
const target = targetMap.get(n.id);
|
||||
if (nfi == null && task == null && !label && !target) return n;
|
||||
return {
|
||||
...n,
|
||||
...(label && label !== n.label ? { label } : {}),
|
||||
...(target ? { next: [target] } : {}),
|
||||
triggerConfig: {
|
||||
...n.triggerConfig,
|
||||
...(nfi != null ? { next_fire_in: nfi } : {}),
|
||||
@@ -1245,14 +1316,15 @@ export default function Workspace() {
|
||||
});
|
||||
|
||||
// Discover new triggers not yet in the graph
|
||||
const entryNode = ss[0].graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const fallbackEntry = ss[0].graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const newNodes: GraphNode[] = [];
|
||||
for (const ep of triggerEps) {
|
||||
const nodeId = `__trigger_${ep.id}`;
|
||||
if (existingIds.has(nodeId)) continue;
|
||||
const target = ep.entry_node || fallbackEntry;
|
||||
newNodes.push({
|
||||
id: nodeId,
|
||||
label: ep.name || ep.id,
|
||||
label: labelMap.get(nodeId) || ep.name || ep.id,
|
||||
status: "pending",
|
||||
nodeType: "trigger",
|
||||
triggerType: ep.trigger_type,
|
||||
@@ -1261,7 +1333,7 @@ export default function Workspace() {
|
||||
...(ep.next_fire_in != null ? { next_fire_in: ep.next_fire_in } : {}),
|
||||
...(ep.task ? { task: ep.task } : {}),
|
||||
},
|
||||
...(entryNode ? { next: [entryNode] } : {}),
|
||||
...(target ? { next: [target] } : {}),
|
||||
});
|
||||
}
|
||||
if (newNodes.length > 0) {
|
||||
@@ -1578,6 +1650,16 @@ export default function Workspace() {
|
||||
const chatMsg = sseEventToChatMessage(event, agentType, displayName, currentTurn);
|
||||
if (isQueen) console.log('[QUEEN] chatMsg:', chatMsg?.id, chatMsg?.content?.slice(0, 50), 'turn:', currentTurn);
|
||||
if (chatMsg && !suppressQueenMessages) {
|
||||
// Queen emits multiple client_output_delta / llm_text_delta snapshots
|
||||
// across iterations and inner tool-loop turns. Build a stable ID that
|
||||
// groups streaming deltas for the *same* output (same execution +
|
||||
// iteration + inner_turn) into one bubble, while keeping distinct
|
||||
// outputs as separate bubbles so earlier text isn't overwritten.
|
||||
if (isQueen && (event.type === "client_output_delta" || event.type === "llm_text_delta") && event.execution_id) {
|
||||
const iter = event.data?.iteration ?? 0;
|
||||
const inner = event.data?.inner_turn ?? 0;
|
||||
chatMsg.id = `queen-stream-${event.execution_id}-${iter}-${inner}`;
|
||||
}
|
||||
if (isQueen) {
|
||||
chatMsg.role = role;
|
||||
chatMsg.phase = queenPhaseRef.current[agentType] as ChatMessage["phase"];
|
||||
@@ -1823,6 +1905,15 @@ export default function Workspace() {
|
||||
const toolName = (event.data?.tool_name as string) || "unknown";
|
||||
const toolUseId = (event.data?.tool_use_id as string) || "";
|
||||
|
||||
// Flag when the queen starts designing/updating the flowchart
|
||||
if (isQueen && toolName === "save_agent_draft") {
|
||||
designingDraftSinceRef.current[agentType] = Date.now();
|
||||
// Clear any pending delayed-clear timer from a previous call
|
||||
const prev = designingDraftTimerRef.current[agentType];
|
||||
if (prev) clearTimeout(prev);
|
||||
updateAgentState(agentType, { designingDraft: true });
|
||||
}
|
||||
|
||||
// Track active (in-flight) tools and upsert activity row into chat
|
||||
const sid = event.stream_id;
|
||||
setAgentStates(prev => {
|
||||
@@ -2030,20 +2121,19 @@ export default function Workspace() {
|
||||
queenBuilding: newPhase === "building",
|
||||
// Sync workerRunState so the RunButton reflects the phase
|
||||
workerRunState: newPhase === "running" ? "running" : "idle",
|
||||
// Clear draft graph once we leave planning/building; keep it during
|
||||
// building so the DraftGraph can show a loading overlay.
|
||||
...(newPhase !== "planning" && newPhase !== "building"
|
||||
? { draftGraph: null }
|
||||
: newPhase === "planning"
|
||||
? { originalDraft: null, flowchartMap: null }
|
||||
: {}),
|
||||
// Clear originalDraft/flowchartMap when re-entering planning.
|
||||
// draftGraph is cleared later when originalDraft arrives, so the
|
||||
// entrance animation has data to render during the handoff.
|
||||
...(newPhase === "planning"
|
||||
? { originalDraft: null, flowchartMap: null }
|
||||
: {}),
|
||||
// Store agent path for credential queries
|
||||
...(eventAgentPath ? { agentPath: eventAgentPath } : {}),
|
||||
});
|
||||
{
|
||||
const sid = agentStates[agentType]?.sessionId;
|
||||
if (sid) {
|
||||
if (newPhase !== "planning") {
|
||||
if (newPhase !== "planning" && newPhase !== "building") {
|
||||
fetchedDraftSessionsRef.current.delete(sid);
|
||||
fetchedFlowchartMapSessionsRef.current.delete(sid);
|
||||
// Fetch the flowchart map (original draft + dissolution mapping)
|
||||
@@ -2053,7 +2143,8 @@ export default function Workspace() {
|
||||
originalDraft: original_draft,
|
||||
});
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
} else if (newPhase === "planning") {
|
||||
// Only clear dedup sets when re-entering planning (not building)
|
||||
fetchedDraftSessionsRef.current.delete(sid);
|
||||
fetchedFlowchartMapSessionsRef.current.delete(sid);
|
||||
}
|
||||
@@ -2066,7 +2157,28 @@ export default function Workspace() {
|
||||
// The draft dict is published directly as event.data (not nested under a key)
|
||||
const draft = event.data as unknown as DraftGraphData | undefined;
|
||||
if (draft?.nodes) {
|
||||
updateAgentState(agentType, { draftGraph: draft });
|
||||
// Ensure the "Designing flowchart…" spinner stays visible for a
|
||||
// minimum duration so users see feedback before the draft appears.
|
||||
const MIN_SPINNER_MS = 600;
|
||||
const since = designingDraftSinceRef.current[agentType] || 0;
|
||||
const elapsed = Date.now() - since;
|
||||
const remaining = Math.max(0, MIN_SPINNER_MS - elapsed);
|
||||
|
||||
const applyDraft = () => {
|
||||
delete designingDraftTimerRef.current[agentType];
|
||||
updateAgentState(agentType, { draftGraph: draft, designingDraft: false });
|
||||
};
|
||||
|
||||
if (remaining > 0 && since > 0) {
|
||||
// Update draftGraph now (so data is ready) but keep spinner visible
|
||||
updateAgentState(agentType, { draftGraph: draft });
|
||||
designingDraftTimerRef.current[agentType] = setTimeout(() => {
|
||||
updateAgentState(agentType, { designingDraft: false });
|
||||
delete designingDraftTimerRef.current[agentType];
|
||||
}, remaining);
|
||||
} else {
|
||||
applyDraft();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -2077,6 +2189,7 @@ export default function Workspace() {
|
||||
updateAgentState(agentType, {
|
||||
flowchartMap: mapData.map ?? null,
|
||||
originalDraft: mapData.original_draft ?? null,
|
||||
draftGraph: null,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -2150,10 +2263,18 @@ export default function Workspace() {
|
||||
// Synthesize new trigger node at the front of the graph
|
||||
const triggerType = (event.data?.trigger_type as string) || "timer";
|
||||
const triggerConfig = (event.data?.trigger_config as Record<string, unknown>) || {};
|
||||
const entryNode = s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const entryNode = (event.data?.entry_node as string) || s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const triggerName = (event.data?.name as string) || triggerId;
|
||||
const _cron = triggerConfig.cron as string | undefined;
|
||||
const _interval = triggerConfig.interval_minutes as number | undefined;
|
||||
const computedLabel = _cron
|
||||
? cronToLabel(_cron)
|
||||
: _interval
|
||||
? `Every ${_interval >= 60 ? `${_interval / 60}h` : `${_interval}m`}`
|
||||
: triggerName;
|
||||
const newNode: GraphNode = {
|
||||
id: nodeId,
|
||||
label: triggerId,
|
||||
label: computedLabel,
|
||||
status: "running",
|
||||
nodeType: "trigger",
|
||||
triggerType,
|
||||
@@ -2218,10 +2339,18 @@ export default function Workspace() {
|
||||
if (s.graphNodes.some(n => n.id === nodeId)) return s;
|
||||
const triggerType = (event.data?.trigger_type as string) || "timer";
|
||||
const triggerConfig = (event.data?.trigger_config as Record<string, unknown>) || {};
|
||||
const entryNode = s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const entryNode = (event.data?.entry_node as string) || s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const triggerName = (event.data?.name as string) || triggerId;
|
||||
const _cron2 = triggerConfig.cron as string | undefined;
|
||||
const _interval2 = triggerConfig.interval_minutes as number | undefined;
|
||||
const computedLabel2 = _cron2
|
||||
? cronToLabel(_cron2)
|
||||
: _interval2
|
||||
? `Every ${_interval2 >= 60 ? `${_interval2 / 60}h` : `${_interval2}m`}`
|
||||
: triggerName;
|
||||
const newNode: GraphNode = {
|
||||
id: nodeId,
|
||||
label: triggerId,
|
||||
label: computedLabel2,
|
||||
status: "pending",
|
||||
nodeType: "trigger",
|
||||
triggerType,
|
||||
@@ -2236,6 +2365,43 @@ export default function Workspace() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "trigger_updated": {
|
||||
const triggerId = event.data?.trigger_id as string;
|
||||
if (triggerId) {
|
||||
const nodeId = `__trigger_${triggerId}`;
|
||||
const triggerConfig = (event.data?.trigger_config as Record<string, unknown>) || {};
|
||||
const cron = triggerConfig.cron as string | undefined;
|
||||
const interval = triggerConfig.interval_minutes as number | undefined;
|
||||
const newLabel = cron
|
||||
? cronToLabel(cron)
|
||||
: interval
|
||||
? `Every ${interval >= 60 ? `${interval / 60}h` : `${interval}m`}`
|
||||
: undefined;
|
||||
setSessionsByAgent(prev => {
|
||||
const sessions = prev[agentType] || [];
|
||||
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
|
||||
return {
|
||||
...prev,
|
||||
[agentType]: sessions.map(s => {
|
||||
if (s.id !== activeId) return s;
|
||||
return {
|
||||
...s,
|
||||
graphNodes: s.graphNodes.map(n => {
|
||||
if (n.id !== nodeId) return n;
|
||||
return {
|
||||
...n,
|
||||
...(newLabel ? { label: newLabel } : {}),
|
||||
triggerConfig: { ...n.triggerConfig, ...triggerConfig },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "trigger_removed": {
|
||||
const triggerId = event.data?.trigger_id as string;
|
||||
if (triggerId) {
|
||||
@@ -2289,14 +2455,43 @@ export default function Workspace() {
|
||||
const liveSelectedNode = selectedNode && currentGraph.nodes.find(n => n.id === selectedNode.id);
|
||||
const resolvedSelectedNode = liveSelectedNode || selectedNode;
|
||||
|
||||
// Sync trigger task draft when selected trigger node changes
|
||||
// Sync trigger drafts when selected trigger node changes
|
||||
useEffect(() => {
|
||||
if (resolvedSelectedNode?.nodeType === "trigger") {
|
||||
const tc = resolvedSelectedNode.triggerConfig as Record<string, unknown> | undefined;
|
||||
setTriggerTaskDraft((tc?.task as string) || "");
|
||||
setTriggerCronDraft((tc?.cron as string) || "");
|
||||
}
|
||||
}, [resolvedSelectedNode?.id]);
|
||||
|
||||
const patchTriggerNode = useCallback((agentType: string, triggerNodeId: string, patch: { task?: string; trigger_config?: Record<string, unknown>; label?: string }) => {
|
||||
setSessionsByAgent(prev => {
|
||||
const sessions = prev[agentType] || [];
|
||||
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
|
||||
return {
|
||||
...prev,
|
||||
[agentType]: sessions.map(s => {
|
||||
if (s.id !== activeId) return s;
|
||||
return {
|
||||
...s,
|
||||
graphNodes: s.graphNodes.map(n => {
|
||||
if (n.id !== triggerNodeId) return n;
|
||||
return {
|
||||
...n,
|
||||
...(patch.label !== undefined ? { label: patch.label } : {}),
|
||||
triggerConfig: {
|
||||
...n.triggerConfig,
|
||||
...(patch.trigger_config || {}),
|
||||
...(patch.task !== undefined ? { task: patch.task } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Build a flat list of all agent-type tabs for the tab bar
|
||||
const agentTabs = Object.entries(sessionsByAgent)
|
||||
.filter(([, sessions]) => sessions.length > 0)
|
||||
@@ -2764,7 +2959,6 @@ export default function Workspace() {
|
||||
|
||||
const activeWorkerLabel = activeAgentState?.displayName || formatAgentDisplayName(baseAgentType(activeWorker));
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background overflow-hidden">
|
||||
<TopBar
|
||||
@@ -2812,38 +3006,40 @@ export default function Workspace() {
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
|
||||
{/* ── Pipeline graph + chat ──────────────────────────────────── */}
|
||||
<div className={`${((activeAgentState?.queenPhase === "planning" || activeAgentState?.queenPhase === "building") && activeAgentState?.draftGraph) || activeAgentState?.originalDraft ? "w-[500px] min-w-[400px]" : "w-[300px] min-w-[240px]"} bg-card/30 flex flex-col border-r border-border/30 transition-[width] duration-200`}>
|
||||
{/* ── Draft flowchart + chat ─────────────────────────────────── */}
|
||||
<div
|
||||
className="bg-card/30 flex flex-col border-r border-border/30 relative"
|
||||
style={{ width: `${graphPanelPct}%`, minWidth: 240, flexShrink: 0 }}
|
||||
>
|
||||
<div className="flex-1 min-h-0">
|
||||
{(activeAgentState?.queenPhase === "planning" || activeAgentState?.queenPhase === "building") && activeAgentState?.draftGraph ? (
|
||||
<DraftGraph draft={activeAgentState.draftGraph} building={activeAgentState?.queenBuilding} onRun={handleRun} onPause={handlePause} runState={activeAgentState?.workerRunState ?? "idle"} />
|
||||
) : activeAgentState?.originalDraft ? (
|
||||
<DraftGraph
|
||||
draft={activeAgentState.originalDraft}
|
||||
building={activeAgentState?.queenBuilding}
|
||||
onRun={handleRun}
|
||||
onPause={handlePause}
|
||||
runState={activeAgentState?.workerRunState ?? "idle"}
|
||||
flowchartMap={activeAgentState.flowchartMap ?? undefined}
|
||||
runtimeNodes={currentGraph.nodes}
|
||||
onRuntimeNodeClick={(runtimeNodeId) => {
|
||||
const node = currentGraph.nodes.find(n => n.id === runtimeNodeId);
|
||||
if (node) setSelectedNode(prev => prev?.id === node.id ? null : node);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AgentGraph
|
||||
nodes={currentGraph.nodes}
|
||||
title={currentGraph.title}
|
||||
onNodeClick={(node) => setSelectedNode(prev => prev?.id === node.id ? null : node)}
|
||||
onRun={handleRun}
|
||||
onPause={handlePause}
|
||||
runState={activeAgentState?.workerRunState ?? "idle"}
|
||||
building={activeAgentState?.queenBuilding ?? false}
|
||||
queenPhase={activeAgentState?.queenPhase ?? "building"}
|
||||
/>
|
||||
)}
|
||||
<DraftGraph
|
||||
key={activeWorker}
|
||||
draft={activeAgentState?.originalDraft ?? activeAgentState?.draftGraph ?? null}
|
||||
originalDraft={activeAgentState?.originalDraft ?? null}
|
||||
loadingMessage={
|
||||
activeAgentState?.designingDraft
|
||||
? "Designing flowchart…"
|
||||
: !activeAgentState?.originalDraft && !activeAgentState?.draftGraph && activeAgentState?.queenPhase !== "planning"
|
||||
? "Loading flowchart…"
|
||||
: null
|
||||
}
|
||||
building={activeAgentState?.queenBuilding}
|
||||
onRun={handleRun}
|
||||
onPause={handlePause}
|
||||
runState={activeAgentState?.workerRunState ?? "idle"}
|
||||
flowchartMap={activeAgentState?.flowchartMap ?? undefined}
|
||||
runtimeNodes={currentGraph.nodes}
|
||||
onRuntimeNodeClick={(runtimeNodeId) => {
|
||||
const node = currentGraph.nodes.find(n => n.id === runtimeNodeId);
|
||||
if (node) setSelectedNode(prev => prev?.id === node.id ? null : node);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/30 active:bg-primary/40 transition-colors z-10"
|
||||
onMouseDown={() => { resizing.current = true; document.body.style.cursor = "col-resize"; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex">
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
@@ -2964,18 +3160,64 @@ export default function Workspace() {
|
||||
const interval = tc?.interval_minutes as number | undefined;
|
||||
const eventTypes = tc?.event_types as string[] | undefined;
|
||||
const scheduleLabel = cron
|
||||
? `cron: ${cron}`
|
||||
? cronToLabel(cron)
|
||||
: interval
|
||||
? `Every ${interval >= 60 ? `${interval / 60}h` : `${interval}m`}`
|
||||
: eventTypes?.length
|
||||
? eventTypes.join(", ")
|
||||
: null;
|
||||
return scheduleLabel ? (
|
||||
const canEditCron = resolvedSelectedNode.triggerType === "timer";
|
||||
const cronChanged = canEditCron && triggerCronDraft.trim() !== (cron || "");
|
||||
return scheduleLabel || canEditCron ? (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Schedule</p>
|
||||
<p className="text-xs text-foreground/80 font-mono bg-muted/30 rounded-lg px-3 py-2 border border-border/20">
|
||||
{scheduleLabel}
|
||||
</p>
|
||||
{scheduleLabel && (
|
||||
<p className="text-xs text-foreground/80 font-mono bg-muted/30 rounded-lg px-3 py-2 border border-border/20">
|
||||
{scheduleLabel}
|
||||
</p>
|
||||
)}
|
||||
{canEditCron && (
|
||||
<>
|
||||
<input
|
||||
value={triggerCronDraft}
|
||||
onChange={(e) => setTriggerCronDraft(e.target.value)}
|
||||
placeholder="0 5 * * *"
|
||||
className="mt-1.5 w-full text-xs text-foreground/80 bg-muted/30 rounded-lg px-3 py-2 border border-border/20 font-mono focus:outline-none focus:border-primary/40"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-1">
|
||||
Edit the cron expression for this timer trigger.
|
||||
</p>
|
||||
{(cronChanged || triggerCronSaved) && (
|
||||
<button
|
||||
disabled={triggerScheduleSaving || !cronChanged}
|
||||
onClick={async () => {
|
||||
const sessionId = activeAgentState?.sessionId;
|
||||
const triggerId = resolvedSelectedNode.id.replace("__trigger_", "");
|
||||
const nextCron = triggerCronDraft.trim();
|
||||
if (!sessionId || !nextCron) return;
|
||||
const nextTriggerConfig: Record<string, unknown> = { cron: nextCron };
|
||||
setTriggerScheduleSaving(true);
|
||||
try {
|
||||
await sessionsApi.updateTrigger(sessionId, triggerId, {
|
||||
trigger_config: nextTriggerConfig,
|
||||
});
|
||||
patchTriggerNode(activeWorker, resolvedSelectedNode.id, {
|
||||
trigger_config: nextTriggerConfig,
|
||||
label: cronToLabel(nextCron),
|
||||
});
|
||||
setTriggerCronSaved(true);
|
||||
setTimeout(() => setTriggerCronSaved(false), 2000);
|
||||
} finally {
|
||||
setTriggerScheduleSaving(false);
|
||||
}
|
||||
}}
|
||||
className="mt-1.5 w-full text-[11px] px-3 py-1.5 rounded-lg border border-primary/30 text-primary hover:bg-primary/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{triggerScheduleSaving ? "Saving..." : triggerCronSaved ? "Saved" : "Save Cron"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
@@ -3002,24 +3244,27 @@ export default function Workspace() {
|
||||
{(() => {
|
||||
const currentTask = (resolvedSelectedNode.triggerConfig as Record<string, unknown> | undefined)?.task as string || "";
|
||||
const hasChanged = triggerTaskDraft !== currentTask;
|
||||
if (!hasChanged) return null;
|
||||
if (!hasChanged && !triggerTaskSaved) return null;
|
||||
return (
|
||||
<button
|
||||
disabled={triggerTaskSaving}
|
||||
disabled={triggerTaskSaving || !hasChanged}
|
||||
onClick={async () => {
|
||||
const sessionId = activeAgentState?.sessionId;
|
||||
const triggerId = resolvedSelectedNode.id.replace("__trigger_", "");
|
||||
if (!sessionId) return;
|
||||
setTriggerTaskSaving(true);
|
||||
try {
|
||||
await sessionsApi.updateTriggerTask(sessionId, triggerId, triggerTaskDraft);
|
||||
await sessionsApi.updateTrigger(sessionId, triggerId, { task: triggerTaskDraft });
|
||||
patchTriggerNode(activeWorker, resolvedSelectedNode.id, { task: triggerTaskDraft });
|
||||
setTriggerTaskSaved(true);
|
||||
setTimeout(() => setTriggerTaskSaved(false), 2000);
|
||||
} finally {
|
||||
setTriggerTaskSaving(false);
|
||||
}
|
||||
}}
|
||||
className="mt-1.5 w-full text-[11px] px-3 py-1.5 rounded-lg border border-primary/30 text-primary hover:bg-primary/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{triggerTaskSaving ? "Saving..." : "Save Task"}
|
||||
{triggerTaskSaving ? "Saving..." : triggerTaskSaved ? "Saved" : "Save Task"}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "framework"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
description = "Goal-driven agent runtime with Builder-friendly observability"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"litellm>=1.81.0",
|
||||
"mcp>=1.0.0",
|
||||
"fastmcp>=2.0.0",
|
||||
"croniter>=1.4.0",
|
||||
"tools",
|
||||
]
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ API_KEY_PROVIDERS = [
|
||||
("TOGETHER_API_KEY", "Together AI", "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
||||
("DEEPSEEK_API_KEY", "DeepSeek", "deepseek-chat"),
|
||||
("MINIMAX_API_KEY", "MiniMax", "MiniMax-M2.5"),
|
||||
("HIVE_API_KEY", "Hive LLM", "hive/queen"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for default skills — parsing, token budget, and configuration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from framework.skills.config import DefaultSkillConfig, SkillsConfig
|
||||
from framework.skills.defaults import (
|
||||
SHARED_MEMORY_KEYS,
|
||||
SKILL_REGISTRY,
|
||||
DefaultSkillManager,
|
||||
)
|
||||
from framework.skills.parser import parse_skill_md
|
||||
|
||||
_DEFAULT_SKILLS_DIR = (
|
||||
Path(__file__).resolve().parent.parent / "framework" / "skills" / "_default_skills"
|
||||
)
|
||||
|
||||
|
||||
class TestDefaultSkillFiles:
|
||||
"""Verify all 6 built-in SKILL.md files parse correctly."""
|
||||
|
||||
def test_all_six_skills_exist(self):
|
||||
assert len(SKILL_REGISTRY) == 6
|
||||
|
||||
@pytest.mark.parametrize("skill_name,dir_name", list(SKILL_REGISTRY.items()))
|
||||
def test_skill_parses(self, skill_name, dir_name):
|
||||
path = _DEFAULT_SKILLS_DIR / dir_name / "SKILL.md"
|
||||
assert path.is_file(), f"Missing SKILL.md at {path}"
|
||||
|
||||
parsed = parse_skill_md(path, source_scope="framework")
|
||||
assert parsed is not None, f"Failed to parse {path}"
|
||||
assert parsed.name == skill_name
|
||||
assert parsed.description
|
||||
assert parsed.body
|
||||
assert parsed.source_scope == "framework"
|
||||
|
||||
def test_combined_token_budget(self):
|
||||
"""All default skill bodies combined should be under 2000 tokens (~8000 chars)."""
|
||||
total_chars = 0
|
||||
for dir_name in SKILL_REGISTRY.values():
|
||||
path = _DEFAULT_SKILLS_DIR / dir_name / "SKILL.md"
|
||||
parsed = parse_skill_md(path, source_scope="framework")
|
||||
assert parsed is not None
|
||||
total_chars += len(parsed.body)
|
||||
|
||||
approx_tokens = total_chars // 4
|
||||
assert approx_tokens < 2000, (
|
||||
f"Combined default skill bodies are ~{approx_tokens} tokens "
|
||||
f"({total_chars} chars), exceeding the 2000 token budget"
|
||||
)
|
||||
|
||||
def test_shared_memory_keys_all_prefixed(self):
|
||||
"""All shared memory keys must start with underscore."""
|
||||
for key in SHARED_MEMORY_KEYS:
|
||||
assert key.startswith("_"), f"Shared memory key missing _ prefix: {key}"
|
||||
|
||||
|
||||
class TestDefaultSkillManager:
|
||||
def test_load_all_defaults(self):
|
||||
manager = DefaultSkillManager()
|
||||
manager.load()
|
||||
|
||||
assert len(manager.active_skill_names) == 6
|
||||
for name in SKILL_REGISTRY:
|
||||
assert name in manager.active_skill_names
|
||||
|
||||
def test_load_idempotent(self):
|
||||
manager = DefaultSkillManager()
|
||||
manager.load()
|
||||
first_skills = dict(manager.active_skills)
|
||||
manager.load()
|
||||
assert manager.active_skills == first_skills
|
||||
|
||||
def test_build_protocols_prompt(self):
|
||||
manager = DefaultSkillManager()
|
||||
manager.load()
|
||||
prompt = manager.build_protocols_prompt()
|
||||
|
||||
assert prompt.startswith("## Operational Protocols")
|
||||
# Should contain content from each active skill
|
||||
for name in SKILL_REGISTRY:
|
||||
skill = manager.active_skills[name]
|
||||
# At least some of the body should appear
|
||||
assert skill.body[:20] in prompt
|
||||
|
||||
def test_protocols_prompt_empty_when_all_disabled(self):
|
||||
config = SkillsConfig(all_defaults_disabled=True)
|
||||
manager = DefaultSkillManager(config)
|
||||
manager.load()
|
||||
|
||||
assert manager.build_protocols_prompt() == ""
|
||||
assert manager.active_skill_names == []
|
||||
|
||||
def test_disable_single_skill(self):
|
||||
config = SkillsConfig.from_agent_vars(
|
||||
default_skills={"hive.quality-monitor": {"enabled": False}}
|
||||
)
|
||||
manager = DefaultSkillManager(config)
|
||||
manager.load()
|
||||
|
||||
assert "hive.quality-monitor" not in manager.active_skill_names
|
||||
assert len(manager.active_skill_names) == 5
|
||||
|
||||
def test_disable_all_via_convention(self):
|
||||
config = SkillsConfig.from_agent_vars(default_skills={"_all": {"enabled": False}})
|
||||
manager = DefaultSkillManager(config)
|
||||
manager.load()
|
||||
|
||||
assert manager.active_skill_names == []
|
||||
|
||||
def test_log_active_skills(self, caplog):
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="framework.skills.defaults"):
|
||||
manager = DefaultSkillManager()
|
||||
manager.load()
|
||||
manager.log_active_skills()
|
||||
|
||||
assert "Default skills active:" in caplog.text
|
||||
|
||||
def test_log_all_disabled(self, caplog):
|
||||
import logging
|
||||
|
||||
config = SkillsConfig(all_defaults_disabled=True)
|
||||
with caplog.at_level(logging.INFO, logger="framework.skills.defaults"):
|
||||
manager = DefaultSkillManager(config)
|
||||
manager.load()
|
||||
manager.log_active_skills()
|
||||
|
||||
assert "all disabled" in caplog.text
|
||||
|
||||
|
||||
class TestSkillsConfig:
|
||||
def test_default_is_enabled(self):
|
||||
config = SkillsConfig()
|
||||
assert config.is_default_enabled("hive.note-taking") is True
|
||||
|
||||
def test_explicit_disable(self):
|
||||
config = SkillsConfig(
|
||||
default_skills={"hive.note-taking": DefaultSkillConfig(enabled=False)}
|
||||
)
|
||||
assert config.is_default_enabled("hive.note-taking") is False
|
||||
assert config.is_default_enabled("hive.batch-ledger") is True
|
||||
|
||||
def test_all_disabled_flag(self):
|
||||
config = SkillsConfig(all_defaults_disabled=True)
|
||||
assert config.is_default_enabled("hive.note-taking") is False
|
||||
assert config.is_default_enabled("anything") is False
|
||||
|
||||
def test_from_agent_vars_basic(self):
|
||||
config = SkillsConfig.from_agent_vars(
|
||||
default_skills={
|
||||
"hive.note-taking": {"enabled": True},
|
||||
"hive.quality-monitor": {"enabled": False},
|
||||
},
|
||||
skills=["deep-research"],
|
||||
)
|
||||
assert config.is_default_enabled("hive.note-taking") is True
|
||||
assert config.is_default_enabled("hive.quality-monitor") is False
|
||||
assert config.skills == ["deep-research"]
|
||||
|
||||
def test_from_agent_vars_bool_shorthand(self):
|
||||
config = SkillsConfig.from_agent_vars(default_skills={"hive.note-taking": False})
|
||||
assert config.is_default_enabled("hive.note-taking") is False
|
||||
|
||||
def test_from_agent_vars_all_disabled(self):
|
||||
config = SkillsConfig.from_agent_vars(default_skills={"_all": {"enabled": False}})
|
||||
assert config.all_defaults_disabled is True
|
||||
|
||||
def test_get_default_overrides(self):
|
||||
config = SkillsConfig.from_agent_vars(
|
||||
default_skills={
|
||||
"hive.batch-ledger": {"enabled": True, "checkpoint_every_n": 10},
|
||||
}
|
||||
)
|
||||
overrides = config.get_default_overrides("hive.batch-ledger")
|
||||
assert overrides == {"checkpoint_every_n": 10}
|
||||
|
||||
def test_get_default_overrides_empty(self):
|
||||
config = SkillsConfig()
|
||||
assert config.get_default_overrides("hive.note-taking") == {}
|
||||
|
||||
def test_from_agent_vars_none_inputs(self):
|
||||
config = SkillsConfig.from_agent_vars(default_skills=None, skills=None)
|
||||
assert config.skills == []
|
||||
assert config.default_skills == {}
|
||||
assert config.all_defaults_disabled is False
|
||||
@@ -12,6 +12,7 @@ Covers:
|
||||
- Single-edge paths unaffected
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -77,6 +78,19 @@ class TimingNode(NodeProtocol):
|
||||
)
|
||||
|
||||
|
||||
class SlowNode(NodeProtocol):
|
||||
"""Sleeps before returning -- used for timeout testing."""
|
||||
|
||||
def __init__(self, delay: float = 10.0):
|
||||
self.delay = delay
|
||||
self.executed = False
|
||||
|
||||
async def execute(self, ctx: NodeContext) -> NodeResult:
|
||||
await asyncio.sleep(self.delay)
|
||||
self.executed = True
|
||||
return NodeResult(success=True, output={"result": "slow"}, tokens_used=1, latency_ms=1)
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
|
||||
@@ -492,3 +506,186 @@ async def test_parallel_disabled_uses_sequential(runtime, goal):
|
||||
# Only one branch should have executed (sequential follows first edge)
|
||||
executed_count = sum([b1_impl.executed, b2_impl.executed])
|
||||
assert executed_count == 1
|
||||
|
||||
|
||||
# === 12. Branch timeout cancels slow branch ===
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_timeout_cancels_slow_branch(runtime, goal):
|
||||
"""A branch exceeding branch_timeout_seconds should be cancelled."""
|
||||
b1 = NodeSpec(
|
||||
id="b1", name="B1", description="slow", node_type="event_loop", output_keys=["b1_out"]
|
||||
)
|
||||
b2 = NodeSpec(
|
||||
id="b2", name="B2", description="fast", node_type="event_loop", output_keys=["b2_out"]
|
||||
)
|
||||
|
||||
graph = _make_fanout_graph([b1, b2])
|
||||
|
||||
config = ParallelExecutionConfig(branch_timeout_seconds=0.1, on_branch_failure="fail_all")
|
||||
executor = GraphExecutor(
|
||||
runtime=runtime, enable_parallel_execution=True, parallel_config=config
|
||||
)
|
||||
executor.register_node("source", SuccessNode({"data": "x"}))
|
||||
executor.register_node("b1", SlowNode(delay=10.0))
|
||||
executor.register_node("b2", SuccessNode({"b2_out": "ok"}))
|
||||
|
||||
result = await executor.execute(graph, goal, {})
|
||||
|
||||
# fail_all: one branch timed out → execution fails
|
||||
assert not result.success
|
||||
assert "failed" in result.error.lower()
|
||||
|
||||
|
||||
# === 13. Branch timeout with continue_others ===
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_timeout_with_continue_others(runtime, goal):
|
||||
"""continue_others should let fast branches finish even when one times out."""
|
||||
b1 = NodeSpec(
|
||||
id="b1", name="B1", description="slow", node_type="event_loop", output_keys=["b1_out"]
|
||||
)
|
||||
b2 = NodeSpec(
|
||||
id="b2", name="B2", description="fast", node_type="event_loop", output_keys=["b2_out"]
|
||||
)
|
||||
|
||||
graph = _make_fanout_graph([b1, b2])
|
||||
|
||||
config = ParallelExecutionConfig(
|
||||
branch_timeout_seconds=0.1, on_branch_failure="continue_others"
|
||||
)
|
||||
executor = GraphExecutor(
|
||||
runtime=runtime, enable_parallel_execution=True, parallel_config=config
|
||||
)
|
||||
executor.register_node("source", SuccessNode({"data": "x"}))
|
||||
executor.register_node("b1", SlowNode(delay=10.0))
|
||||
b2_impl = SuccessNode({"b2_out": "ok"})
|
||||
executor.register_node("b2", b2_impl)
|
||||
|
||||
await executor.execute(graph, goal, {})
|
||||
|
||||
# continue_others tolerates the timeout
|
||||
assert b2_impl.executed
|
||||
|
||||
|
||||
# === 14. Branch timeout with fail_all (explicit) ===
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_timeout_with_fail_all(runtime, goal):
|
||||
"""fail_all should propagate timeout as execution failure."""
|
||||
b1 = NodeSpec(
|
||||
id="b1", name="B1", description="slow", node_type="event_loop", output_keys=["b1_out"]
|
||||
)
|
||||
b2 = NodeSpec(
|
||||
id="b2", name="B2", description="also slow", node_type="event_loop", output_keys=["b2_out"]
|
||||
)
|
||||
|
||||
graph = _make_fanout_graph([b1, b2])
|
||||
|
||||
config = ParallelExecutionConfig(branch_timeout_seconds=0.1, on_branch_failure="fail_all")
|
||||
executor = GraphExecutor(
|
||||
runtime=runtime, enable_parallel_execution=True, parallel_config=config
|
||||
)
|
||||
executor.register_node("source", SuccessNode({"data": "x"}))
|
||||
executor.register_node("b1", SlowNode(delay=10.0))
|
||||
executor.register_node("b2", SlowNode(delay=10.0))
|
||||
|
||||
result = await executor.execute(graph, goal, {})
|
||||
|
||||
assert not result.success
|
||||
|
||||
|
||||
# === 15. Memory conflict: last_wins ===
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_conflict_last_wins(runtime, goal):
|
||||
"""last_wins should allow both branches to write the same key without error."""
|
||||
# Use distinct output_keys in spec (to pass graph validation) but have
|
||||
# the node impl write a shared key at runtime — this is the scenario
|
||||
# memory_conflict_strategy is designed to handle.
|
||||
b1 = NodeSpec(
|
||||
id="b1", name="B1", description="b1", node_type="event_loop", output_keys=["b1_out"]
|
||||
)
|
||||
b2 = NodeSpec(
|
||||
id="b2", name="B2", description="b2", node_type="event_loop", output_keys=["b2_out"]
|
||||
)
|
||||
|
||||
graph = _make_fanout_graph([b1, b2])
|
||||
|
||||
config = ParallelExecutionConfig(memory_conflict_strategy="last_wins")
|
||||
executor = GraphExecutor(
|
||||
runtime=runtime, enable_parallel_execution=True, parallel_config=config
|
||||
)
|
||||
executor.register_node("source", SuccessNode({"data": "x"}))
|
||||
# Both impls write "shared_key" — triggers conflict detection at runtime
|
||||
executor.register_node("b1", SuccessNode({"shared_key": "from_b1", "b1_out": "ok"}))
|
||||
executor.register_node("b2", SuccessNode({"shared_key": "from_b2", "b2_out": "ok"}))
|
||||
|
||||
result = await executor.execute(graph, goal, {})
|
||||
|
||||
assert result.success
|
||||
# The key should exist with one of the two values
|
||||
assert result.output.get("shared_key") in ("from_b1", "from_b2")
|
||||
|
||||
|
||||
# === 16. Memory conflict: first_wins ===
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_conflict_first_wins(runtime, goal):
|
||||
"""first_wins should keep the first branch's value and skip later writes."""
|
||||
b1 = NodeSpec(
|
||||
id="b1", name="B1", description="b1", node_type="event_loop", output_keys=["b1_out"]
|
||||
)
|
||||
b2 = NodeSpec(
|
||||
id="b2", name="B2", description="b2", node_type="event_loop", output_keys=["b2_out"]
|
||||
)
|
||||
|
||||
graph = _make_fanout_graph([b1, b2])
|
||||
|
||||
config = ParallelExecutionConfig(memory_conflict_strategy="first_wins")
|
||||
executor = GraphExecutor(
|
||||
runtime=runtime, enable_parallel_execution=True, parallel_config=config
|
||||
)
|
||||
executor.register_node("source", SuccessNode({"data": "x"}))
|
||||
executor.register_node("b1", SuccessNode({"shared_key": "from_b1", "b1_out": "ok"}))
|
||||
executor.register_node("b2", SuccessNode({"shared_key": "from_b2", "b2_out": "ok"}))
|
||||
|
||||
result = await executor.execute(graph, goal, {})
|
||||
|
||||
assert result.success
|
||||
|
||||
|
||||
# === 17. Memory conflict: error raises ===
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_conflict_error_raises(runtime, goal):
|
||||
"""error strategy should fail when two branches write the same key."""
|
||||
b1 = NodeSpec(
|
||||
id="b1", name="B1", description="b1", node_type="event_loop", output_keys=["b1_out"]
|
||||
)
|
||||
b2 = NodeSpec(
|
||||
id="b2", name="B2", description="b2", node_type="event_loop", output_keys=["b2_out"]
|
||||
)
|
||||
|
||||
graph = _make_fanout_graph([b1, b2])
|
||||
|
||||
config = ParallelExecutionConfig(memory_conflict_strategy="error")
|
||||
executor = GraphExecutor(
|
||||
runtime=runtime, enable_parallel_execution=True, parallel_config=config
|
||||
)
|
||||
executor.register_node("source", SuccessNode({"data": "x"}))
|
||||
executor.register_node("b1", SuccessNode({"shared_key": "from_b1", "b1_out": "ok"}))
|
||||
executor.register_node("b2", SuccessNode({"shared_key": "from_b2", "b2_out": "ok"}))
|
||||
|
||||
result = await executor.execute(graph, goal, {})
|
||||
|
||||
assert not result.success
|
||||
# The conflict RuntimeError is caught inside execute_single_branch,
|
||||
# which causes the branch to fail. fail_all then raises its own error.
|
||||
assert "failed" in result.error.lower()
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Tests for framework/tools/flowchart_utils.py."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from framework.tools.flowchart_utils import (
|
||||
FLOWCHART_FILENAME,
|
||||
FLOWCHART_TYPES,
|
||||
classify_flowchart_node,
|
||||
generate_fallback_flowchart,
|
||||
load_flowchart_file,
|
||||
save_flowchart_file,
|
||||
synthesize_draft_from_runtime,
|
||||
)
|
||||
|
||||
|
||||
def _make_node(
|
||||
id,
|
||||
name="Node",
|
||||
description="",
|
||||
node_type="event_loop",
|
||||
tools=None,
|
||||
input_keys=None,
|
||||
output_keys=None,
|
||||
success_criteria="",
|
||||
sub_agents=None,
|
||||
):
|
||||
"""Create a minimal node-like object matching NodeSpec interface."""
|
||||
return SimpleNamespace(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
node_type=node_type,
|
||||
tools=tools or [],
|
||||
input_keys=input_keys or [],
|
||||
output_keys=output_keys or [],
|
||||
success_criteria=success_criteria,
|
||||
sub_agents=sub_agents or [],
|
||||
)
|
||||
|
||||
|
||||
def _make_edge(source, target, condition="on_success", description=""):
|
||||
"""Create a minimal edge-like object matching EdgeSpec interface."""
|
||||
return SimpleNamespace(
|
||||
source=source,
|
||||
target=target,
|
||||
condition=SimpleNamespace(value=condition),
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def _make_goal(
|
||||
name="Test Goal", description="A test goal", success_criteria=None, constraints=None
|
||||
):
|
||||
"""Create a minimal goal-like object matching Goal interface."""
|
||||
return SimpleNamespace(
|
||||
name=name,
|
||||
description=description,
|
||||
success_criteria=success_criteria or [],
|
||||
constraints=constraints or [],
|
||||
)
|
||||
|
||||
|
||||
def _make_graph(nodes, edges, entry_node=None, terminal_nodes=None):
|
||||
"""Create a minimal graph-like object matching GraphSpec interface."""
|
||||
return SimpleNamespace(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
entry_node=entry_node or (nodes[0].id if nodes else ""),
|
||||
terminal_nodes=terminal_nodes or [],
|
||||
)
|
||||
|
||||
|
||||
class TestClassifyFlowchartNode:
|
||||
"""Test flowchart node classification logic."""
|
||||
|
||||
def test_first_node_is_start(self):
|
||||
node = {"id": "n1", "node_type": "event_loop", "tools": []}
|
||||
result = classify_flowchart_node(node, 0, 3, [], set())
|
||||
assert result == "start"
|
||||
|
||||
def test_terminal_node(self):
|
||||
node = {"id": "n3", "node_type": "event_loop", "tools": []}
|
||||
edges = [{"source": "n1", "target": "n3"}]
|
||||
result = classify_flowchart_node(node, 2, 3, edges, {"n3"})
|
||||
assert result == "terminal"
|
||||
|
||||
def test_gcu_node_is_browser(self):
|
||||
node = {"id": "n2", "node_type": "gcu", "tools": []}
|
||||
edges = [{"source": "n1", "target": "n2"}]
|
||||
result = classify_flowchart_node(node, 1, 3, edges, set())
|
||||
assert result == "browser"
|
||||
|
||||
def test_subprocess_node(self):
|
||||
node = {"id": "n2", "node_type": "event_loop", "tools": [], "sub_agents": ["sub1"]}
|
||||
edges = [{"source": "n1", "target": "n2"}, {"source": "n2", "target": "n3"}]
|
||||
result = classify_flowchart_node(node, 1, 3, edges, set())
|
||||
assert result == "subprocess"
|
||||
|
||||
def test_default_is_process(self):
|
||||
node = {"id": "n2", "node_type": "event_loop", "tools": [], "description": "do stuff"}
|
||||
edges = [{"source": "n1", "target": "n2"}, {"source": "n2", "target": "n3"}]
|
||||
result = classify_flowchart_node(node, 1, 3, edges, set())
|
||||
assert result == "process"
|
||||
|
||||
def test_explicit_override(self):
|
||||
node = {"id": "n2", "node_type": "event_loop", "tools": [], "flowchart_type": "database"}
|
||||
edges = [{"source": "n1", "target": "n2"}]
|
||||
result = classify_flowchart_node(node, 1, 3, edges, set())
|
||||
assert result == "database"
|
||||
|
||||
def test_decision_node_with_branching(self):
|
||||
node = {"id": "n2", "node_type": "event_loop", "tools": []}
|
||||
edges = [
|
||||
{"source": "n1", "target": "n2"},
|
||||
{"source": "n2", "target": "n3", "condition": "on_success"},
|
||||
{"source": "n2", "target": "n4", "condition": "on_failure"},
|
||||
]
|
||||
result = classify_flowchart_node(node, 1, 4, edges, set())
|
||||
assert result == "decision"
|
||||
|
||||
|
||||
class TestSynthesizeDraftFromRuntime:
|
||||
"""Test runtime graph to DraftGraph conversion."""
|
||||
|
||||
def test_basic_linear_graph(self):
|
||||
nodes = [
|
||||
_make_node("intake", "Intake"),
|
||||
_make_node("process", "Process"),
|
||||
_make_node("deliver", "Deliver"),
|
||||
]
|
||||
edges = [
|
||||
_make_edge("intake", "process"),
|
||||
_make_edge("process", "deliver"),
|
||||
]
|
||||
draft, fmap = synthesize_draft_from_runtime(
|
||||
nodes, edges, agent_name="test_agent", goal_name="Test"
|
||||
)
|
||||
|
||||
assert draft["agent_name"] == "test_agent"
|
||||
assert draft["goal"] == "Test"
|
||||
assert len(draft["nodes"]) == 3
|
||||
assert len(draft["edges"]) == 2
|
||||
assert draft["entry_node"] == "intake"
|
||||
assert "deliver" in draft["terminal_nodes"]
|
||||
|
||||
# First node should be start type
|
||||
assert draft["nodes"][0]["flowchart_type"] == "start"
|
||||
# Last node (terminal) should be terminal type
|
||||
assert draft["nodes"][2]["flowchart_type"] == "terminal"
|
||||
# Middle node should be process
|
||||
assert draft["nodes"][1]["flowchart_type"] == "process"
|
||||
|
||||
# All nodes should have shape and color
|
||||
for node in draft["nodes"]:
|
||||
assert "flowchart_shape" in node
|
||||
assert "flowchart_color" in node
|
||||
|
||||
# Flowchart map should be identity
|
||||
assert fmap == {"intake": ["intake"], "process": ["process"], "deliver": ["deliver"]}
|
||||
|
||||
# Legend should contain all types
|
||||
assert draft["flowchart_legend"] == {
|
||||
k: {"shape": v["shape"], "color": v["color"]} for k, v in FLOWCHART_TYPES.items()
|
||||
}
|
||||
|
||||
def test_graph_with_sub_agents(self):
|
||||
nodes = [
|
||||
_make_node("main", "Main", sub_agents=["helper"]),
|
||||
_make_node("helper", "Helper"),
|
||||
]
|
||||
edges = [_make_edge("main", "helper")]
|
||||
draft, fmap = synthesize_draft_from_runtime(nodes, edges)
|
||||
|
||||
# Sub-agent edges should be added
|
||||
assert len(draft["edges"]) > 1
|
||||
|
||||
# Helper should be grouped under main in the flowchart map
|
||||
assert "helper" not in fmap
|
||||
assert fmap["main"] == ["main", "helper"]
|
||||
|
||||
|
||||
class TestFlowchartFilePersistence:
|
||||
"""Test save/load of flowchart.json."""
|
||||
|
||||
def test_save_and_load(self, tmp_path):
|
||||
draft = {"agent_name": "test", "nodes": [], "edges": []}
|
||||
fmap = {"n1": ["n1"]}
|
||||
|
||||
save_flowchart_file(tmp_path, draft, fmap)
|
||||
loaded_draft, loaded_map = load_flowchart_file(tmp_path)
|
||||
|
||||
assert loaded_draft == draft
|
||||
assert loaded_map == fmap
|
||||
|
||||
def test_load_missing_file(self, tmp_path):
|
||||
draft, fmap = load_flowchart_file(tmp_path)
|
||||
assert draft is None
|
||||
assert fmap is None
|
||||
|
||||
def test_load_none_path(self):
|
||||
draft, fmap = load_flowchart_file(None)
|
||||
assert draft is None
|
||||
assert fmap is None
|
||||
|
||||
def test_save_none_path(self):
|
||||
# Should not raise
|
||||
save_flowchart_file(None, {}, {})
|
||||
|
||||
|
||||
class TestGenerateFallbackFlowchart:
|
||||
"""Test the main entry point for fallback generation."""
|
||||
|
||||
def test_generates_file_when_missing(self, tmp_path):
|
||||
nodes = [
|
||||
_make_node("n1", "Start Node"),
|
||||
_make_node("n2", "End Node"),
|
||||
]
|
||||
edges = [_make_edge("n1", "n2")]
|
||||
graph = _make_graph(nodes, edges, entry_node="n1", terminal_nodes=["n2"])
|
||||
goal = _make_goal()
|
||||
|
||||
generate_fallback_flowchart(graph, goal, tmp_path)
|
||||
|
||||
flowchart_path = tmp_path / FLOWCHART_FILENAME
|
||||
assert flowchart_path.exists()
|
||||
|
||||
data = json.loads(flowchart_path.read_text())
|
||||
assert data["original_draft"]["agent_name"] == tmp_path.name
|
||||
assert data["original_draft"]["goal"] == "A test goal"
|
||||
assert data["flowchart_map"] is not None
|
||||
# Entry/terminal from GraphSpec should be used
|
||||
assert data["original_draft"]["entry_node"] == "n1"
|
||||
assert "n2" in data["original_draft"]["terminal_nodes"]
|
||||
|
||||
def test_skips_when_file_exists(self, tmp_path):
|
||||
# Pre-create a flowchart.json
|
||||
existing = {"original_draft": {"agent_name": "existing"}, "flowchart_map": {}}
|
||||
(tmp_path / FLOWCHART_FILENAME).write_text(json.dumps(existing))
|
||||
|
||||
nodes = [_make_node("n1", "Node")]
|
||||
graph = _make_graph(nodes, [], entry_node="n1")
|
||||
goal = _make_goal()
|
||||
|
||||
generate_fallback_flowchart(graph, goal, tmp_path)
|
||||
|
||||
# Should not have been overwritten
|
||||
data = json.loads((tmp_path / FLOWCHART_FILENAME).read_text())
|
||||
assert data["original_draft"]["agent_name"] == "existing"
|
||||
|
||||
def test_handles_errors_gracefully(self, tmp_path):
|
||||
# Pass an invalid path (file, not directory)
|
||||
fake_path = tmp_path / "not_a_dir.txt"
|
||||
fake_path.write_text("hello")
|
||||
|
||||
graph = _make_graph([], [])
|
||||
goal = _make_goal()
|
||||
|
||||
# Should not raise
|
||||
generate_fallback_flowchart(graph, goal, fake_path)
|
||||
|
||||
def test_enriches_with_goal_metadata(self, tmp_path):
|
||||
nodes = [_make_node("n1", "Node")]
|
||||
graph = _make_graph(nodes, [], entry_node="n1")
|
||||
goal = _make_goal(
|
||||
description="Find bugs",
|
||||
success_criteria=[SimpleNamespace(description="All bugs found")],
|
||||
constraints=[SimpleNamespace(description="No false positives")],
|
||||
)
|
||||
|
||||
generate_fallback_flowchart(graph, goal, tmp_path)
|
||||
|
||||
data = json.loads((tmp_path / FLOWCHART_FILENAME).read_text())
|
||||
assert data["original_draft"]["goal"] == "Find bugs"
|
||||
assert data["original_draft"]["success_criteria"] == ["All bugs found"]
|
||||
assert data["original_draft"]["constraints"] == ["No false positives"]
|
||||
@@ -3,12 +3,16 @@ Tests for core GraphExecutor execution paths.
|
||||
Focused on minimal success and failure scenarios.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.executor import GraphExecutor
|
||||
from framework.graph.goal import Goal
|
||||
from framework.graph.node import NodeResult, NodeSpec
|
||||
from framework.utils.io import atomic_write
|
||||
|
||||
|
||||
# ---- Dummy runtime (no real logging) ----
|
||||
@@ -25,6 +29,14 @@ class DummyRuntime:
|
||||
pass
|
||||
|
||||
|
||||
class DummyMemory:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read_all(self):
|
||||
return self._data
|
||||
|
||||
|
||||
# ---- Fake node that always succeeds ----
|
||||
class SuccessNode:
|
||||
def validate_input(self, ctx):
|
||||
@@ -245,3 +257,61 @@ async def test_executor_no_events_without_event_bus():
|
||||
result = await executor.execute(graph=graph, goal=goal)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
|
||||
def test_write_progress_uses_atomic_write_and_updates_state(tmp_path, monkeypatch):
|
||||
runtime = DummyRuntime()
|
||||
executor = GraphExecutor(runtime=runtime, storage_path=tmp_path)
|
||||
state_path = tmp_path / "state.json"
|
||||
state_path.write_text(json.dumps({"entry_point": "primary"}), encoding="utf-8")
|
||||
memory = DummyMemory({"foo": "bar"})
|
||||
|
||||
called = {}
|
||||
|
||||
def recording_atomic_write(path, *args, **kwargs):
|
||||
called["path"] = path
|
||||
return atomic_write(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("framework.graph.executor.atomic_write", recording_atomic_write)
|
||||
|
||||
executor._write_progress(
|
||||
current_node="node-b",
|
||||
path=["node-a", "node-b"],
|
||||
memory=memory,
|
||||
node_visit_counts={"node-a": 1, "node-b": 1},
|
||||
)
|
||||
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert called["path"] == state_path
|
||||
assert state["entry_point"] == "primary"
|
||||
assert state["progress"]["current_node"] == "node-b"
|
||||
assert state["progress"]["path"] == ["node-a", "node-b"]
|
||||
assert state["progress"]["node_visit_counts"] == {"node-a": 1, "node-b": 1}
|
||||
assert state["progress"]["steps_executed"] == 2
|
||||
assert state["memory"] == {"foo": "bar"}
|
||||
assert state["memory_keys"] == ["foo"]
|
||||
assert "updated_at" in state["timestamps"]
|
||||
|
||||
|
||||
def test_write_progress_logs_warning_on_atomic_write_failure(tmp_path, monkeypatch, caplog):
|
||||
runtime = DummyRuntime()
|
||||
executor = GraphExecutor(runtime=runtime, storage_path=tmp_path)
|
||||
state_path = tmp_path / "state.json"
|
||||
state_path.write_text(json.dumps({"entry_point": "primary"}), encoding="utf-8")
|
||||
memory = DummyMemory({"foo": "bar"})
|
||||
|
||||
def failing_atomic_write(*args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr("framework.graph.executor.atomic_write", failing_atomic_write)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
executor._write_progress(
|
||||
current_node="node-b",
|
||||
path=["node-a", "node-b"],
|
||||
memory=memory,
|
||||
node_visit_counts={"node-a": 1, "node-b": 1},
|
||||
)
|
||||
|
||||
assert "Failed to persist progress state to" in caplog.text
|
||||
assert str(state_path) in caplog.text
|
||||
|
||||
@@ -338,6 +338,69 @@ class TestLLMJudgeBackwardCompatibility:
|
||||
assert call_kwargs["model"] == "claude-haiku-4-5-20251001"
|
||||
assert call_kwargs["max_tokens"] == 500
|
||||
|
||||
def test_openai_fallback_uses_litellm_provider(self, monkeypatch):
|
||||
"""When OPENAI_API_KEY is set, evaluate() should use a LiteLLM-based provider."""
|
||||
# Force the OpenAI fallback path (no injected provider, no Anthropic key)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-openai")
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
|
||||
# Stub LiteLLMProvider so we don't call the real API; record what judge passes through
|
||||
captured_calls: list[dict] = []
|
||||
|
||||
class DummyProvider:
|
||||
def __init__(self, model: str = "gpt-4o-mini"):
|
||||
self.model = model
|
||||
|
||||
def complete(
|
||||
self,
|
||||
messages,
|
||||
system="",
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
response_format=None,
|
||||
json_mode=False,
|
||||
max_retries=None,
|
||||
):
|
||||
captured_calls.append(
|
||||
{
|
||||
"messages": messages,
|
||||
"system": system,
|
||||
"max_tokens": max_tokens,
|
||||
"json_mode": json_mode,
|
||||
"model": self.model,
|
||||
}
|
||||
)
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
|
||||
# Minimal response object with a content attribute
|
||||
return _Resp('{"passes": true, "explanation": "OK"}')
|
||||
|
||||
monkeypatch.setattr(
|
||||
"framework.llm.litellm.LiteLLMProvider",
|
||||
DummyProvider,
|
||||
)
|
||||
|
||||
judge = LLMJudge()
|
||||
result = judge.evaluate(
|
||||
constraint="no-hallucination",
|
||||
source_document="The sky is blue.",
|
||||
summary="The sky is blue.",
|
||||
criteria="Summary must only contain facts from source",
|
||||
)
|
||||
|
||||
# Judge should have used our stub once and returned the stub's JSON result
|
||||
assert result["passes"] is True
|
||||
assert result["explanation"] == "OK"
|
||||
assert len(captured_calls) == 1
|
||||
|
||||
call = captured_calls[0]
|
||||
assert call["model"] == "gpt-4o-mini"
|
||||
assert call["max_tokens"] == 500
|
||||
assert call["json_mode"] is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LLMJudge Integration Pattern Tests
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Tests for safe_eval — the sandboxed expression evaluator used by edge conditions.
|
||||
|
||||
Covers: literals, data structures, arithmetic, comparisons, boolean logic
|
||||
(including short-circuit semantics), variable lookup, subscript/attribute
|
||||
access, whitelisted function calls, method calls, ternary expressions,
|
||||
chained comparisons, and security boundaries (private attrs, disallowed
|
||||
AST nodes, disallowed function calls).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from framework.graph.safe_eval import safe_eval
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Literals and constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLiterals:
|
||||
def test_integer(self):
|
||||
assert safe_eval("42") == 42
|
||||
|
||||
def test_negative_integer(self):
|
||||
assert safe_eval("-1") == -1
|
||||
|
||||
def test_float(self):
|
||||
assert safe_eval("3.14") == pytest.approx(3.14)
|
||||
|
||||
def test_string(self):
|
||||
assert safe_eval("'hello'") == "hello"
|
||||
|
||||
def test_double_quoted_string(self):
|
||||
assert safe_eval('"world"') == "world"
|
||||
|
||||
def test_boolean_true(self):
|
||||
assert safe_eval("True") is True
|
||||
|
||||
def test_boolean_false(self):
|
||||
assert safe_eval("False") is False
|
||||
|
||||
def test_none(self):
|
||||
assert safe_eval("None") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataStructures:
|
||||
def test_list(self):
|
||||
assert safe_eval("[1, 2, 3]") == [1, 2, 3]
|
||||
|
||||
def test_empty_list(self):
|
||||
assert safe_eval("[]") == []
|
||||
|
||||
def test_nested_list(self):
|
||||
assert safe_eval("[[1, 2], [3, 4]]") == [[1, 2], [3, 4]]
|
||||
|
||||
def test_tuple(self):
|
||||
assert safe_eval("(1, 2, 3)") == (1, 2, 3)
|
||||
|
||||
def test_dict(self):
|
||||
assert safe_eval("{'a': 1, 'b': 2}") == {"a": 1, "b": 2}
|
||||
|
||||
def test_empty_dict(self):
|
||||
assert safe_eval("{}") == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arithmetic and binary operators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArithmetic:
|
||||
def test_addition(self):
|
||||
assert safe_eval("2 + 3") == 5
|
||||
|
||||
def test_subtraction(self):
|
||||
assert safe_eval("10 - 4") == 6
|
||||
|
||||
def test_multiplication(self):
|
||||
assert safe_eval("3 * 7") == 21
|
||||
|
||||
def test_division(self):
|
||||
assert safe_eval("10 / 4") == 2.5
|
||||
|
||||
def test_floor_division(self):
|
||||
assert safe_eval("10 // 3") == 3
|
||||
|
||||
def test_modulo(self):
|
||||
assert safe_eval("10 % 3") == 1
|
||||
|
||||
def test_power(self):
|
||||
assert safe_eval("2 ** 10") == 1024
|
||||
|
||||
def test_complex_expression(self):
|
||||
assert safe_eval("(2 + 3) * 4 - 1") == 19
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unary operators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnaryOps:
|
||||
def test_negation(self):
|
||||
assert safe_eval("-5") == -5
|
||||
|
||||
def test_positive(self):
|
||||
assert safe_eval("+5") == 5
|
||||
|
||||
def test_not_true(self):
|
||||
assert safe_eval("not True") is False
|
||||
|
||||
def test_not_false(self):
|
||||
assert safe_eval("not False") is True
|
||||
|
||||
def test_bitwise_invert(self):
|
||||
assert safe_eval("~0") == -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comparisons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComparisons:
|
||||
def test_equal(self):
|
||||
assert safe_eval("1 == 1") is True
|
||||
|
||||
def test_not_equal(self):
|
||||
assert safe_eval("1 != 2") is True
|
||||
|
||||
def test_less_than(self):
|
||||
assert safe_eval("1 < 2") is True
|
||||
|
||||
def test_greater_than(self):
|
||||
assert safe_eval("2 > 1") is True
|
||||
|
||||
def test_less_equal(self):
|
||||
assert safe_eval("2 <= 2") is True
|
||||
|
||||
def test_greater_equal(self):
|
||||
assert safe_eval("3 >= 2") is True
|
||||
|
||||
def test_is_none(self):
|
||||
assert safe_eval("x is None", {"x": None}) is True
|
||||
|
||||
def test_is_not_none(self):
|
||||
assert safe_eval("x is not None", {"x": 42}) is True
|
||||
|
||||
def test_in_list(self):
|
||||
assert safe_eval("'a' in x", {"x": ["a", "b", "c"]}) is True
|
||||
|
||||
def test_not_in_list(self):
|
||||
assert safe_eval("'z' not in x", {"x": ["a", "b"]}) is True
|
||||
|
||||
def test_chained_comparison(self):
|
||||
"""Chained comparisons like 1 < x < 10 should work."""
|
||||
assert safe_eval("1 < x < 10", {"x": 5}) is True
|
||||
|
||||
def test_chained_comparison_false(self):
|
||||
assert safe_eval("1 < x < 3", {"x": 5}) is False
|
||||
|
||||
def test_chained_three_way(self):
|
||||
assert safe_eval("0 <= x <= 100", {"x": 50}) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boolean operators (with short-circuit semantics)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBooleanOps:
|
||||
def test_and_true(self):
|
||||
assert safe_eval("True and True") is True
|
||||
|
||||
def test_and_false(self):
|
||||
assert safe_eval("True and False") is False
|
||||
|
||||
def test_or_true(self):
|
||||
assert safe_eval("False or True") is True
|
||||
|
||||
def test_or_false(self):
|
||||
assert safe_eval("False or False") is False
|
||||
|
||||
def test_and_returns_last_truthy(self):
|
||||
"""Python `and` returns the last value if all truthy."""
|
||||
assert safe_eval("1 and 2 and 3") == 3
|
||||
|
||||
def test_and_returns_first_falsy(self):
|
||||
"""Python `and` returns the first falsy value."""
|
||||
assert safe_eval("1 and 0 and 3") == 0
|
||||
|
||||
def test_or_returns_first_truthy(self):
|
||||
"""Python `or` returns the first truthy value."""
|
||||
assert safe_eval("0 or '' or 42") == 42
|
||||
|
||||
def test_or_returns_last_falsy(self):
|
||||
"""Python `or` returns the last value if all falsy."""
|
||||
assert safe_eval("0 or '' or None") is None
|
||||
|
||||
def test_and_short_circuits(self):
|
||||
"""and should NOT evaluate the right side if left is falsy.
|
||||
|
||||
This is the bug we fixed — previously this would crash with
|
||||
TypeError because all operands were eagerly evaluated.
|
||||
"""
|
||||
# x is None, so `x.get("key")` would crash if evaluated
|
||||
assert safe_eval("x is not None and x.get('key')", {"x": None}) is False
|
||||
|
||||
def test_or_short_circuits(self):
|
||||
"""or should NOT evaluate the right side if left is truthy."""
|
||||
# x is truthy, so the crash-prone right side should never run
|
||||
assert safe_eval("x or y.get('missing')", {"x": "found", "y": {}}) == "found"
|
||||
|
||||
def test_and_guard_pattern_truthy(self):
|
||||
"""Guard pattern: check not None, then access — when value exists."""
|
||||
ctx = {"x": {"key": "value"}}
|
||||
assert safe_eval("x is not None and x.get('key')", ctx) == "value"
|
||||
|
||||
def test_multi_and(self):
|
||||
assert safe_eval("True and True and True") is True
|
||||
|
||||
def test_multi_or(self):
|
||||
assert safe_eval("False or False or True") is True
|
||||
|
||||
def test_mixed_and_or(self):
|
||||
assert safe_eval("True or False and False") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ternary (if/else) expressions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTernary:
|
||||
def test_ternary_true_branch(self):
|
||||
assert safe_eval("'yes' if True else 'no'") == "yes"
|
||||
|
||||
def test_ternary_false_branch(self):
|
||||
assert safe_eval("'yes' if False else 'no'") == "no"
|
||||
|
||||
def test_ternary_with_context(self):
|
||||
assert safe_eval("x * 2 if x > 0 else -x", {"x": 5}) == 10
|
||||
|
||||
def test_ternary_false_with_context(self):
|
||||
assert safe_eval("x * 2 if x > 0 else -x", {"x": -3}) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variable lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVariables:
|
||||
def test_simple_variable(self):
|
||||
assert safe_eval("x", {"x": 42}) == 42
|
||||
|
||||
def test_string_variable(self):
|
||||
assert safe_eval("name", {"name": "Alice"}) == "Alice"
|
||||
|
||||
def test_dict_variable(self):
|
||||
ctx = {"output": {"status": "ok"}}
|
||||
assert safe_eval("output", ctx) == {"status": "ok"}
|
||||
|
||||
def test_undefined_variable_raises(self):
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("undefined_var")
|
||||
|
||||
def test_multiple_variables(self):
|
||||
assert safe_eval("x + y", {"x": 10, "y": 20}) == 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subscript access (indexing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubscript:
|
||||
def test_dict_subscript(self):
|
||||
assert safe_eval("d['key']", {"d": {"key": "value"}}) == "value"
|
||||
|
||||
def test_list_subscript(self):
|
||||
assert safe_eval("items[0]", {"items": [10, 20, 30]}) == 10
|
||||
|
||||
def test_nested_subscript(self):
|
||||
ctx = {"data": {"users": [{"name": "Alice"}]}}
|
||||
assert safe_eval("data['users'][0]['name']", ctx) == "Alice"
|
||||
|
||||
def test_missing_key_raises(self):
|
||||
with pytest.raises(KeyError):
|
||||
safe_eval("d['missing']", {"d": {}})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attribute access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAttributeAccess:
|
||||
def test_private_attr_blocked(self):
|
||||
"""Attributes starting with _ must be blocked for security."""
|
||||
with pytest.raises(ValueError, match="private attribute"):
|
||||
safe_eval("x.__class__", {"x": 42})
|
||||
|
||||
def test_dunder_blocked(self):
|
||||
with pytest.raises(ValueError, match="private attribute"):
|
||||
safe_eval("x.__dict__", {"x": {}})
|
||||
|
||||
def test_single_underscore_blocked(self):
|
||||
with pytest.raises(ValueError, match="private attribute"):
|
||||
safe_eval("x._internal", {"x": {}})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whitelisted function calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFunctionCalls:
|
||||
def test_len(self):
|
||||
assert safe_eval("len(x)", {"x": [1, 2, 3]}) == 3
|
||||
|
||||
def test_int_conversion(self):
|
||||
assert safe_eval("int('42')") == 42
|
||||
|
||||
def test_float_conversion(self):
|
||||
assert safe_eval("float('3.14')") == pytest.approx(3.14)
|
||||
|
||||
def test_str_conversion(self):
|
||||
assert safe_eval("str(42)") == "42"
|
||||
|
||||
def test_bool_conversion(self):
|
||||
assert safe_eval("bool(1)") is True
|
||||
|
||||
def test_abs(self):
|
||||
assert safe_eval("abs(-5)") == 5
|
||||
|
||||
def test_min(self):
|
||||
assert safe_eval("min(3, 1, 2)") == 1
|
||||
|
||||
def test_max(self):
|
||||
assert safe_eval("max(3, 1, 2)") == 3
|
||||
|
||||
def test_sum(self):
|
||||
assert safe_eval("sum(x)", {"x": [1, 2, 3]}) == 6
|
||||
|
||||
def test_round(self):
|
||||
assert safe_eval("round(3.7)") == 4
|
||||
|
||||
def test_all(self):
|
||||
assert safe_eval("all([True, True, True])") is True
|
||||
|
||||
def test_any(self):
|
||||
assert safe_eval("any([False, False, True])") is True
|
||||
|
||||
def test_list_constructor(self):
|
||||
assert safe_eval("list(x)", {"x": (1, 2, 3)}) == [1, 2, 3]
|
||||
|
||||
def test_dict_constructor(self):
|
||||
assert safe_eval("dict(a=1, b=2)") == {"a": 1, "b": 2}
|
||||
|
||||
def test_tuple_constructor(self):
|
||||
assert safe_eval("tuple(x)", {"x": [1, 2]}) == (1, 2)
|
||||
|
||||
def test_set_constructor(self):
|
||||
assert safe_eval("set(x)", {"x": [1, 2, 2, 3]}) == {1, 2, 3}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whitelisted method calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMethodCalls:
|
||||
def test_dict_get(self):
|
||||
assert safe_eval("d.get('key', 'default')", {"d": {"key": "val"}}) == "val"
|
||||
|
||||
def test_dict_get_missing(self):
|
||||
assert safe_eval("d.get('missing', 'default')", {"d": {}}) == "default"
|
||||
|
||||
def test_dict_keys(self):
|
||||
result = safe_eval("list(d.keys())", {"d": {"a": 1, "b": 2}})
|
||||
assert sorted(result) == ["a", "b"]
|
||||
|
||||
def test_dict_values(self):
|
||||
result = safe_eval("list(d.values())", {"d": {"a": 1, "b": 2}})
|
||||
assert sorted(result) == [1, 2]
|
||||
|
||||
def test_string_lower(self):
|
||||
assert safe_eval("s.lower()", {"s": "HELLO"}) == "hello"
|
||||
|
||||
def test_string_upper(self):
|
||||
assert safe_eval("s.upper()", {"s": "hello"}) == "HELLO"
|
||||
|
||||
def test_string_strip(self):
|
||||
assert safe_eval("s.strip()", {"s": " hi "}) == "hi"
|
||||
|
||||
def test_string_split(self):
|
||||
assert safe_eval("s.split(',')", {"s": "a,b,c"}) == ["a", "b", "c"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security: disallowed operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSecurity:
|
||||
def test_import_blocked(self):
|
||||
"""__import__ is not in context, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("__import__('os')")
|
||||
|
||||
def test_lambda_blocked(self):
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
safe_eval("(lambda: 1)()")
|
||||
|
||||
def test_comprehension_blocked(self):
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
safe_eval("[x for x in range(10)]")
|
||||
|
||||
def test_assignment_blocked(self):
|
||||
"""Assignment expressions should not parse in eval mode."""
|
||||
with pytest.raises(SyntaxError):
|
||||
safe_eval("x = 5")
|
||||
|
||||
def test_disallowed_function_blocked(self):
|
||||
"""eval is not in safe functions, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("eval('1+1')")
|
||||
|
||||
def test_exec_blocked(self):
|
||||
"""exec is not in safe functions, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("exec('x=1')")
|
||||
|
||||
def test_type_call_blocked(self):
|
||||
"""type is not in safe functions, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("type(42)")
|
||||
|
||||
def test_getattr_builtin_blocked(self):
|
||||
"""getattr is not in safe functions, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("getattr(x, '__class__')", {"x": 42})
|
||||
|
||||
def test_empty_expression_raises(self):
|
||||
with pytest.raises(SyntaxError):
|
||||
safe_eval("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real-world edge condition patterns (from graph executor usage)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeConditionPatterns:
|
||||
"""Patterns commonly used in EdgeSpec.condition_expr."""
|
||||
|
||||
def test_output_key_exists_and_not_none(self):
|
||||
ctx = {"output": {"approved_contacts": ["alice@example.com"]}}
|
||||
assert safe_eval("output.get('approved_contacts') is not None", ctx) is True
|
||||
|
||||
def test_output_key_missing(self):
|
||||
ctx = {"output": {}}
|
||||
assert safe_eval("output.get('approved_contacts') is not None", ctx) is False
|
||||
|
||||
def test_output_key_check_with_fallback(self):
|
||||
ctx = {"output": {"redo_extraction": True}}
|
||||
assert safe_eval("output.get('redo_extraction') is not None", ctx) is True
|
||||
|
||||
def test_guard_then_length_check(self):
|
||||
"""Guard pattern: check key exists, then check length."""
|
||||
ctx = {"output": {"results": [1, 2, 3]}}
|
||||
assert (
|
||||
safe_eval(
|
||||
"output.get('results') is not None and len(output['results']) > 0",
|
||||
ctx,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_guard_short_circuits_on_none(self):
|
||||
"""Guard pattern: short-circuit prevents crash on None."""
|
||||
ctx = {"output": {}}
|
||||
assert (
|
||||
safe_eval(
|
||||
"output.get('results') is not None and len(output['results']) > 0",
|
||||
ctx,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_success_flag_check(self):
|
||||
ctx = {"output": {"success": True}, "memory": {"attempts": 2}}
|
||||
assert safe_eval("output.get('success') == True", ctx) is True
|
||||
|
||||
def test_memory_threshold(self):
|
||||
ctx = {"memory": {"score": 0.85}}
|
||||
assert safe_eval("memory.get('score', 0) >= 0.8", ctx) is True
|
||||
|
||||
def test_string_contains_check(self):
|
||||
ctx = {"output": {"status": "completed_with_warnings"}}
|
||||
assert safe_eval("'completed' in output.get('status', '')", ctx) is True
|
||||
|
||||
def test_fallback_chain(self):
|
||||
"""or-chain for fallback values."""
|
||||
ctx = {"output": {}}
|
||||
result = safe_eval(
|
||||
"output.get('primary') or output.get('secondary') or 'default'",
|
||||
ctx,
|
||||
)
|
||||
assert result == "default"
|
||||
|
||||
def test_no_context_needed(self):
|
||||
"""Some edges use constant expressions."""
|
||||
assert safe_eval("True") is True
|
||||
assert safe_eval("1 == 1") is True
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for the skill catalog and prompt generation."""
|
||||
|
||||
from framework.skills.catalog import SkillCatalog
|
||||
from framework.skills.parser import ParsedSkill
|
||||
|
||||
|
||||
def _make_skill(
|
||||
name: str = "my-skill",
|
||||
description: str = "A test skill.",
|
||||
source_scope: str = "project",
|
||||
body: str = "Instructions here.",
|
||||
location: str = "/tmp/skills/my-skill/SKILL.md",
|
||||
base_dir: str = "/tmp/skills/my-skill",
|
||||
) -> ParsedSkill:
|
||||
return ParsedSkill(
|
||||
name=name,
|
||||
description=description,
|
||||
location=location,
|
||||
base_dir=base_dir,
|
||||
source_scope=source_scope,
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
class TestSkillCatalog:
|
||||
def test_add_and_get(self):
|
||||
catalog = SkillCatalog()
|
||||
skill = _make_skill()
|
||||
catalog.add(skill)
|
||||
|
||||
assert catalog.get("my-skill") is skill
|
||||
assert catalog.get("nonexistent") is None
|
||||
assert catalog.skill_count == 1
|
||||
|
||||
def test_init_with_skills_list(self):
|
||||
skills = [_make_skill("a", "Skill A"), _make_skill("b", "Skill B")]
|
||||
catalog = SkillCatalog(skills)
|
||||
|
||||
assert catalog.skill_count == 2
|
||||
assert catalog.get("a") is not None
|
||||
assert catalog.get("b") is not None
|
||||
|
||||
def test_activation_tracking(self):
|
||||
catalog = SkillCatalog([_make_skill()])
|
||||
assert not catalog.is_activated("my-skill")
|
||||
|
||||
catalog.mark_activated("my-skill")
|
||||
assert catalog.is_activated("my-skill")
|
||||
|
||||
def test_allowlisted_dirs(self):
|
||||
skills = [
|
||||
_make_skill("a", base_dir="/skills/a"),
|
||||
_make_skill("b", base_dir="/skills/b"),
|
||||
]
|
||||
catalog = SkillCatalog(skills)
|
||||
dirs = catalog.allowlisted_dirs
|
||||
|
||||
assert "/skills/a" in dirs
|
||||
assert "/skills/b" in dirs
|
||||
|
||||
def test_to_prompt_empty_catalog(self):
|
||||
catalog = SkillCatalog()
|
||||
assert catalog.to_prompt() == ""
|
||||
|
||||
def test_to_prompt_framework_only(self):
|
||||
"""Framework-scope skills should NOT appear in the catalog prompt."""
|
||||
catalog = SkillCatalog([_make_skill(source_scope="framework")])
|
||||
assert catalog.to_prompt() == ""
|
||||
|
||||
def test_to_prompt_xml_generation(self):
|
||||
skills = [
|
||||
_make_skill("alpha", "Alpha skill", "project", location="/p/alpha/SKILL.md"),
|
||||
_make_skill("beta", "Beta skill", "user", location="/u/beta/SKILL.md"),
|
||||
]
|
||||
catalog = SkillCatalog(skills)
|
||||
prompt = catalog.to_prompt()
|
||||
|
||||
assert "<available_skills>" in prompt
|
||||
assert "</available_skills>" in prompt
|
||||
assert "<name>alpha</name>" in prompt
|
||||
assert "<name>beta</name>" in prompt
|
||||
assert "<description>Alpha skill</description>" in prompt
|
||||
assert "<location>/p/alpha/SKILL.md</location>" in prompt
|
||||
|
||||
def test_to_prompt_sorted_by_name(self):
|
||||
skills = [
|
||||
_make_skill("zebra", "Z skill", "project"),
|
||||
_make_skill("alpha", "A skill", "project"),
|
||||
]
|
||||
catalog = SkillCatalog(skills)
|
||||
prompt = catalog.to_prompt()
|
||||
|
||||
alpha_pos = prompt.index("alpha")
|
||||
zebra_pos = prompt.index("zebra")
|
||||
assert alpha_pos < zebra_pos
|
||||
|
||||
def test_to_prompt_xml_escaping(self):
|
||||
skill = _make_skill("test", 'Has <special> & "chars"', "project")
|
||||
catalog = SkillCatalog([skill])
|
||||
prompt = catalog.to_prompt()
|
||||
|
||||
assert "<special>" in prompt
|
||||
assert "&" in prompt
|
||||
|
||||
def test_to_prompt_excludes_framework_includes_others(self):
|
||||
"""Mixed scopes: only framework skills are excluded from catalog."""
|
||||
skills = [
|
||||
_make_skill("proj", "Project skill", "project"),
|
||||
_make_skill("usr", "User skill", "user"),
|
||||
_make_skill("fw", "Framework skill", "framework"),
|
||||
]
|
||||
catalog = SkillCatalog(skills)
|
||||
prompt = catalog.to_prompt()
|
||||
|
||||
assert "<name>proj</name>" in prompt
|
||||
assert "<name>usr</name>" in prompt
|
||||
assert "fw" not in prompt
|
||||
|
||||
def test_to_prompt_contains_behavioral_instruction(self):
|
||||
catalog = SkillCatalog([_make_skill(source_scope="project")])
|
||||
prompt = catalog.to_prompt()
|
||||
|
||||
assert "When a task matches a skill's description" in prompt
|
||||
assert "SKILL.md" in prompt
|
||||
|
||||
def test_build_pre_activated_prompt(self):
|
||||
skill = _make_skill("research", body="## Deep Research\nDo thorough research.")
|
||||
catalog = SkillCatalog([skill])
|
||||
prompt = catalog.build_pre_activated_prompt(["research"])
|
||||
|
||||
assert "Pre-Activated Skill: research" in prompt
|
||||
assert "## Deep Research" in prompt
|
||||
assert catalog.is_activated("research")
|
||||
|
||||
def test_build_pre_activated_skips_already_activated(self):
|
||||
skill = _make_skill("research", body="Research body")
|
||||
catalog = SkillCatalog([skill])
|
||||
catalog.mark_activated("research")
|
||||
|
||||
prompt = catalog.build_pre_activated_prompt(["research"])
|
||||
assert prompt == ""
|
||||
|
||||
def test_build_pre_activated_missing_skill(self):
|
||||
catalog = SkillCatalog()
|
||||
prompt = catalog.build_pre_activated_prompt(["nonexistent"])
|
||||
assert prompt == ""
|
||||
|
||||
def test_build_pre_activated_multiple(self):
|
||||
skills = [
|
||||
_make_skill("a", body="Body A"),
|
||||
_make_skill("b", body="Body B"),
|
||||
]
|
||||
catalog = SkillCatalog(skills)
|
||||
prompt = catalog.build_pre_activated_prompt(["a", "b"])
|
||||
|
||||
assert "Pre-Activated Skill: a" in prompt
|
||||
assert "Body A" in prompt
|
||||
assert "Pre-Activated Skill: b" in prompt
|
||||
assert "Body B" in prompt
|
||||
assert catalog.is_activated("a")
|
||||
assert catalog.is_activated("b")
|
||||
|
||||
def test_duplicate_add_overwrites(self):
|
||||
"""Adding a skill with the same name replaces the previous one."""
|
||||
catalog = SkillCatalog()
|
||||
catalog.add(_make_skill("x", "First"))
|
||||
catalog.add(_make_skill("x", "Second"))
|
||||
|
||||
assert catalog.skill_count == 1
|
||||
assert catalog.get("x").description == "Second"
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests for skill discovery."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from framework.skills.discovery import DiscoveryConfig, SkillDiscovery
|
||||
|
||||
|
||||
def _write_skill(base: Path, name: str, description: str = "A test skill.") -> Path:
|
||||
"""Create a minimal skill directory with SKILL.md."""
|
||||
skill_dir = base / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\n---\n\nInstructions.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
|
||||
class TestSkillDiscovery:
|
||||
def test_discover_project_skills(self, tmp_path):
|
||||
# Create project-level skills
|
||||
agents_skills = tmp_path / ".agents" / "skills"
|
||||
_write_skill(agents_skills, "skill-a")
|
||||
_write_skill(agents_skills, "skill-b")
|
||||
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path,
|
||||
skip_user_scope=True,
|
||||
skip_framework_scope=True,
|
||||
)
|
||||
)
|
||||
skills = discovery.discover()
|
||||
|
||||
names = {s.name for s in skills}
|
||||
assert "skill-a" in names
|
||||
assert "skill-b" in names
|
||||
assert all(s.source_scope == "project" for s in skills)
|
||||
|
||||
def test_hive_skills_path(self, tmp_path):
|
||||
hive_skills = tmp_path / ".hive" / "skills"
|
||||
_write_skill(hive_skills, "hive-skill")
|
||||
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path,
|
||||
skip_user_scope=True,
|
||||
skip_framework_scope=True,
|
||||
)
|
||||
)
|
||||
skills = discovery.discover()
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].name == "hive-skill"
|
||||
|
||||
def test_collision_project_overrides_user(self, tmp_path, monkeypatch):
|
||||
# User-level skill
|
||||
user_skills = tmp_path / "home" / ".agents" / "skills"
|
||||
_write_skill(user_skills, "shared-skill", "User version")
|
||||
|
||||
# Project-level skill with same name
|
||||
project_skills = tmp_path / "project" / ".agents" / "skills"
|
||||
_write_skill(project_skills, "shared-skill", "Project version")
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
|
||||
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path / "project",
|
||||
skip_framework_scope=True,
|
||||
)
|
||||
)
|
||||
skills = discovery.discover()
|
||||
|
||||
matching = [s for s in skills if s.name == "shared-skill"]
|
||||
assert len(matching) == 1
|
||||
assert matching[0].description == "Project version"
|
||||
|
||||
def test_collision_hive_overrides_agents(self, tmp_path):
|
||||
# Cross-client path
|
||||
agents_skills = tmp_path / ".agents" / "skills"
|
||||
_write_skill(agents_skills, "override-test", "Agents version")
|
||||
|
||||
# Hive-specific path (higher precedence)
|
||||
hive_skills = tmp_path / ".hive" / "skills"
|
||||
_write_skill(hive_skills, "override-test", "Hive version")
|
||||
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path,
|
||||
skip_user_scope=True,
|
||||
skip_framework_scope=True,
|
||||
)
|
||||
)
|
||||
skills = discovery.discover()
|
||||
|
||||
matching = [s for s in skills if s.name == "override-test"]
|
||||
assert len(matching) == 1
|
||||
assert matching[0].description == "Hive version"
|
||||
|
||||
def test_skips_git_and_node_modules(self, tmp_path):
|
||||
skills_dir = tmp_path / ".agents" / "skills"
|
||||
_write_skill(skills_dir / ".git", "git-skill")
|
||||
_write_skill(skills_dir / "node_modules", "npm-skill")
|
||||
_write_skill(skills_dir, "real-skill")
|
||||
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path,
|
||||
skip_user_scope=True,
|
||||
skip_framework_scope=True,
|
||||
)
|
||||
)
|
||||
skills = discovery.discover()
|
||||
|
||||
names = {s.name for s in skills}
|
||||
assert "real-skill" in names
|
||||
assert "git-skill" not in names
|
||||
assert "npm-skill" not in names
|
||||
|
||||
def test_empty_scan(self, tmp_path):
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path,
|
||||
skip_user_scope=True,
|
||||
skip_framework_scope=True,
|
||||
)
|
||||
)
|
||||
skills = discovery.discover()
|
||||
assert skills == []
|
||||
|
||||
def test_framework_scope_loads_defaults(self):
|
||||
"""Framework scope should find the built-in default skills."""
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
skip_user_scope=True,
|
||||
)
|
||||
)
|
||||
skills = discovery.discover()
|
||||
|
||||
framework_skills = [s for s in skills if s.source_scope == "framework"]
|
||||
names = {s.name for s in framework_skills}
|
||||
assert "hive.note-taking" in names
|
||||
assert "hive.batch-ledger" in names
|
||||
|
||||
def test_max_depth_limit(self, tmp_path):
|
||||
# Create a skill nested beyond max_depth
|
||||
deep = tmp_path / ".agents" / "skills" / "a" / "b" / "c" / "d" / "e"
|
||||
_write_skill(deep, "too-deep")
|
||||
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path,
|
||||
skip_user_scope=True,
|
||||
skip_framework_scope=True,
|
||||
max_depth=2,
|
||||
)
|
||||
)
|
||||
skills = discovery.discover()
|
||||
assert not any(s.name == "too-deep" for s in skills)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Integration tests for the skill system — prompt composition and backward compatibility."""
|
||||
|
||||
from framework.graph.prompt_composer import compose_system_prompt
|
||||
from framework.skills.catalog import SkillCatalog
|
||||
from framework.skills.config import SkillsConfig
|
||||
from framework.skills.defaults import DefaultSkillManager
|
||||
from framework.skills.discovery import DiscoveryConfig, SkillDiscovery
|
||||
from framework.skills.parser import ParsedSkill
|
||||
|
||||
|
||||
def _make_skill(
|
||||
name: str = "test-skill",
|
||||
description: str = "A test skill.",
|
||||
source_scope: str = "project",
|
||||
body: str = "Skill instructions.",
|
||||
location: str = "/tmp/skills/test-skill/SKILL.md",
|
||||
base_dir: str = "/tmp/skills/test-skill",
|
||||
) -> ParsedSkill:
|
||||
return ParsedSkill(
|
||||
name=name,
|
||||
description=description,
|
||||
location=location,
|
||||
base_dir=base_dir,
|
||||
source_scope=source_scope,
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
class TestPromptComposition:
|
||||
"""Test that skill prompts integrate correctly with compose_system_prompt."""
|
||||
|
||||
def test_backward_compat_no_skill_params(self):
|
||||
"""compose_system_prompt works without skill params (backward compat)."""
|
||||
prompt = compose_system_prompt(
|
||||
identity_prompt="You are a helpful agent.",
|
||||
focus_prompt="Focus on the task.",
|
||||
)
|
||||
assert "You are a helpful agent." in prompt
|
||||
assert "Focus on the task." in prompt
|
||||
assert "Current date and time" in prompt
|
||||
|
||||
def test_skills_catalog_in_prompt(self):
|
||||
catalog = SkillCatalog([_make_skill(source_scope="project")])
|
||||
catalog_prompt = catalog.to_prompt()
|
||||
|
||||
prompt = compose_system_prompt(
|
||||
identity_prompt="You are an agent.",
|
||||
focus_prompt=None,
|
||||
skills_catalog_prompt=catalog_prompt,
|
||||
)
|
||||
assert "<available_skills>" in prompt
|
||||
assert "<name>test-skill</name>" in prompt
|
||||
|
||||
def test_protocols_in_prompt(self):
|
||||
manager = DefaultSkillManager()
|
||||
manager.load()
|
||||
protocols_prompt = manager.build_protocols_prompt()
|
||||
|
||||
prompt = compose_system_prompt(
|
||||
identity_prompt="You are an agent.",
|
||||
focus_prompt=None,
|
||||
protocols_prompt=protocols_prompt,
|
||||
)
|
||||
assert "## Operational Protocols" in prompt
|
||||
|
||||
def test_full_prompt_ordering(self):
|
||||
"""Verify the three-layer onion ordering with all sections present."""
|
||||
catalog = SkillCatalog([_make_skill(source_scope="project")])
|
||||
|
||||
prompt = compose_system_prompt(
|
||||
identity_prompt="IDENTITY_SECTION",
|
||||
focus_prompt="FOCUS_SECTION",
|
||||
narrative="NARRATIVE_SECTION",
|
||||
accounts_prompt="ACCOUNTS_SECTION",
|
||||
skills_catalog_prompt=catalog.to_prompt(),
|
||||
protocols_prompt="PROTOCOLS_SECTION",
|
||||
)
|
||||
|
||||
identity_pos = prompt.index("IDENTITY_SECTION")
|
||||
accounts_pos = prompt.index("ACCOUNTS_SECTION")
|
||||
skills_pos = prompt.index("available_skills")
|
||||
protocols_pos = prompt.index("PROTOCOLS_SECTION")
|
||||
narrative_pos = prompt.index("NARRATIVE_SECTION")
|
||||
focus_pos = prompt.index("FOCUS_SECTION")
|
||||
|
||||
# Identity → Accounts → Skills → Protocols → Narrative → Focus
|
||||
assert identity_pos < accounts_pos
|
||||
assert accounts_pos < skills_pos
|
||||
assert skills_pos < protocols_pos
|
||||
assert protocols_pos < narrative_pos
|
||||
assert narrative_pos < focus_pos
|
||||
|
||||
def test_none_skill_prompts_excluded(self):
|
||||
"""None values for skill prompts should not add content."""
|
||||
prompt = compose_system_prompt(
|
||||
identity_prompt="Hello",
|
||||
focus_prompt=None,
|
||||
skills_catalog_prompt=None,
|
||||
protocols_prompt=None,
|
||||
)
|
||||
assert "available_skills" not in prompt
|
||||
assert "Operational Protocols" not in prompt
|
||||
|
||||
def test_empty_skill_prompts_excluded(self):
|
||||
"""Empty string skill prompts should not add content."""
|
||||
prompt = compose_system_prompt(
|
||||
identity_prompt="Hello",
|
||||
focus_prompt=None,
|
||||
skills_catalog_prompt="",
|
||||
protocols_prompt="",
|
||||
)
|
||||
assert "available_skills" not in prompt
|
||||
assert "Operational Protocols" not in prompt
|
||||
|
||||
|
||||
class TestEndToEndPipeline:
|
||||
"""Test the full discovery → catalog → prompt pipeline."""
|
||||
|
||||
def test_discovery_to_catalog_to_prompt(self, tmp_path):
|
||||
# Create a project skill
|
||||
skill_dir = tmp_path / ".agents" / "skills" / "my-tool"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-tool\ndescription: Tool for testing.\n---\n\n"
|
||||
"## Usage\nUse this tool when testing.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Discovery
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path,
|
||||
skip_user_scope=True,
|
||||
skip_framework_scope=True,
|
||||
)
|
||||
)
|
||||
skills = discovery.discover()
|
||||
assert len(skills) == 1
|
||||
|
||||
# Catalog
|
||||
catalog = SkillCatalog(skills)
|
||||
assert catalog.skill_count == 1
|
||||
|
||||
# Prompt generation
|
||||
prompt = catalog.to_prompt()
|
||||
assert "<name>my-tool</name>" in prompt
|
||||
assert "<description>Tool for testing.</description>" in prompt
|
||||
|
||||
# Pre-activation
|
||||
activated = catalog.build_pre_activated_prompt(["my-tool"])
|
||||
assert "## Usage" in activated
|
||||
assert catalog.is_activated("my-tool")
|
||||
|
||||
def test_defaults_plus_community_skills(self, tmp_path):
|
||||
"""Default skills and community skills produce separate prompt sections."""
|
||||
# Create a community skill
|
||||
skill_dir = tmp_path / ".agents" / "skills" / "community-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: community-skill\ndescription: A community skill.\n---\n\nDo stuff.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Discover community skills
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path,
|
||||
skip_user_scope=True,
|
||||
skip_framework_scope=True,
|
||||
)
|
||||
)
|
||||
community_skills = discovery.discover()
|
||||
catalog = SkillCatalog(community_skills)
|
||||
catalog_prompt = catalog.to_prompt()
|
||||
|
||||
# Load default skills
|
||||
manager = DefaultSkillManager()
|
||||
manager.load()
|
||||
protocols_prompt = manager.build_protocols_prompt()
|
||||
|
||||
# Compose
|
||||
prompt = compose_system_prompt(
|
||||
identity_prompt="Agent identity.",
|
||||
focus_prompt=None,
|
||||
skills_catalog_prompt=catalog_prompt,
|
||||
protocols_prompt=protocols_prompt,
|
||||
)
|
||||
|
||||
# Both sections present
|
||||
assert "<available_skills>" in prompt
|
||||
assert "<name>community-skill</name>" in prompt
|
||||
assert "## Operational Protocols" in prompt
|
||||
|
||||
def test_config_disables_defaults_keeps_community(self, tmp_path):
|
||||
"""Disabling all defaults should still allow community skills."""
|
||||
skill_dir = tmp_path / ".agents" / "skills" / "still-here"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: still-here\ndescription: Survives config.\n---\n\nBody.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Community skills
|
||||
discovery = SkillDiscovery(
|
||||
DiscoveryConfig(
|
||||
project_root=tmp_path,
|
||||
skip_user_scope=True,
|
||||
skip_framework_scope=True,
|
||||
)
|
||||
)
|
||||
catalog = SkillCatalog(discovery.discover())
|
||||
|
||||
# Disabled defaults
|
||||
config = SkillsConfig(all_defaults_disabled=True)
|
||||
manager = DefaultSkillManager(config)
|
||||
manager.load()
|
||||
|
||||
catalog_prompt = catalog.to_prompt()
|
||||
protocols_prompt = manager.build_protocols_prompt()
|
||||
|
||||
assert "<name>still-here</name>" in catalog_prompt
|
||||
assert protocols_prompt == ""
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Tests for SKILL.md parser."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from framework.skills.parser import parse_skill_md
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_skill(tmp_path):
|
||||
"""Helper to create a SKILL.md file and return its path."""
|
||||
|
||||
def _create(content: str, dir_name: str = "my-skill") -> Path:
|
||||
skill_dir = tmp_path / dir_name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(content, encoding="utf-8")
|
||||
return skill_md
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
class TestParseSkillMd:
|
||||
def test_happy_path(self, tmp_skill):
|
||||
content = """---
|
||||
name: my-skill
|
||||
description: A test skill for unit testing.
|
||||
license: MIT
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
Do the thing.
|
||||
"""
|
||||
result = parse_skill_md(tmp_skill(content), source_scope="project")
|
||||
assert result is not None
|
||||
assert result.name == "my-skill"
|
||||
assert result.description == "A test skill for unit testing."
|
||||
assert result.license == "MIT"
|
||||
assert result.source_scope == "project"
|
||||
assert "Do the thing." in result.body
|
||||
|
||||
def test_missing_description_returns_none(self, tmp_skill):
|
||||
content = """---
|
||||
name: no-desc
|
||||
---
|
||||
|
||||
Body here.
|
||||
"""
|
||||
result = parse_skill_md(tmp_skill(content, "no-desc"))
|
||||
assert result is None
|
||||
|
||||
def test_missing_name_uses_directory(self, tmp_skill):
|
||||
content = """---
|
||||
description: Skill without a name field.
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
result = parse_skill_md(tmp_skill(content, "fallback-dir"))
|
||||
assert result is not None
|
||||
assert result.name == "fallback-dir"
|
||||
|
||||
def test_empty_file_returns_none(self, tmp_skill):
|
||||
result = parse_skill_md(tmp_skill("", "empty"))
|
||||
assert result is None
|
||||
|
||||
def test_no_frontmatter_delimiters_returns_none(self, tmp_skill):
|
||||
content = "Just plain text without YAML frontmatter."
|
||||
result = parse_skill_md(tmp_skill(content, "no-yaml"))
|
||||
assert result is None
|
||||
|
||||
def test_unparseable_yaml_returns_none(self, tmp_skill):
|
||||
content = """---
|
||||
name: [invalid yaml
|
||||
- broken: {{
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
result = parse_skill_md(tmp_skill(content, "bad-yaml"))
|
||||
assert result is None
|
||||
|
||||
def test_unquoted_colon_fixup(self, tmp_skill):
|
||||
content = """---
|
||||
name: colon-test
|
||||
description: Use for: research tasks
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
result = parse_skill_md(tmp_skill(content, "colon-test"))
|
||||
assert result is not None
|
||||
assert "research tasks" in result.description
|
||||
|
||||
def test_long_name_warns_but_loads(self, tmp_skill):
|
||||
long_name = "a" * 100
|
||||
content = f"""---
|
||||
name: {long_name}
|
||||
description: A skill with an excessively long name.
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
result = parse_skill_md(tmp_skill(content, "long-name"))
|
||||
assert result is not None
|
||||
assert result.name == long_name
|
||||
|
||||
def test_name_mismatch_warns_but_loads(self, tmp_skill):
|
||||
content = """---
|
||||
name: different-name
|
||||
description: Name doesn't match directory.
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
result = parse_skill_md(tmp_skill(content, "actual-dir"))
|
||||
assert result is not None
|
||||
assert result.name == "different-name"
|
||||
|
||||
def test_optional_fields(self, tmp_skill):
|
||||
content = """---
|
||||
name: full-skill
|
||||
description: Skill with all optional fields.
|
||||
license: Apache-2.0
|
||||
compatibility:
|
||||
- claude-code
|
||||
- cursor
|
||||
metadata:
|
||||
author: tester
|
||||
version: "1.0"
|
||||
allowed-tools:
|
||||
- web_search
|
||||
- read_file
|
||||
---
|
||||
|
||||
Instructions here.
|
||||
"""
|
||||
result = parse_skill_md(tmp_skill(content, "full-skill"))
|
||||
assert result is not None
|
||||
assert result.license == "Apache-2.0"
|
||||
assert result.compatibility == ["claude-code", "cursor"]
|
||||
assert result.metadata == {"author": "tester", "version": "1.0"}
|
||||
assert result.allowed_tools == ["web_search", "read_file"]
|
||||
|
||||
def test_body_extraction(self, tmp_skill):
|
||||
content = """---
|
||||
name: body-test
|
||||
description: Test body extraction.
|
||||
---
|
||||
|
||||
## Step 1
|
||||
|
||||
Do this first.
|
||||
|
||||
## Step 2
|
||||
|
||||
Then do this.
|
||||
"""
|
||||
result = parse_skill_md(tmp_skill(content, "body-test"))
|
||||
assert result is not None
|
||||
assert "## Step 1" in result.body
|
||||
assert "## Step 2" in result.body
|
||||
assert "Do this first." in result.body
|
||||
|
||||
def test_location_is_absolute(self, tmp_skill):
|
||||
content = """---
|
||||
name: abs-path
|
||||
description: Check absolute path.
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
path = tmp_skill(content, "abs-path")
|
||||
result = parse_skill_md(path)
|
||||
assert result is not None
|
||||
assert Path(result.location).is_absolute()
|
||||
assert Path(result.base_dir).is_absolute()
|
||||
|
||||
def test_nonexistent_file_returns_none(self, tmp_path):
|
||||
result = parse_skill_md(tmp_path / "nonexistent" / "SKILL.md")
|
||||
assert result is None
|
||||
@@ -299,6 +299,66 @@ class TestSubagentExecution:
|
||||
assert "metadata" in result_data
|
||||
assert result_data["metadata"]["agent_id"] == "researcher"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcu_subagent_auto_populates_tools_from_catalog(self, runtime):
|
||||
"""GCU subagent with tools=[] should receive all catalog tools (auto-populate).
|
||||
|
||||
GCU nodes declare tools=[] because the runner expands them at setup time.
|
||||
But _execute_subagent filters by subagent_spec.tools, which is still empty.
|
||||
The fix: when subagent is GCU with no declared tools, include all catalog tools.
|
||||
"""
|
||||
gcu_spec = NodeSpec(
|
||||
id="browser_worker",
|
||||
name="Browser Worker",
|
||||
description="GCU browser subagent",
|
||||
node_type="gcu",
|
||||
output_keys=["result"],
|
||||
tools=[], # Empty — expects auto-population
|
||||
)
|
||||
|
||||
parent_spec = NodeSpec(
|
||||
id="parent",
|
||||
name="Parent",
|
||||
description="Orchestrator",
|
||||
node_type="event_loop",
|
||||
output_keys=["result"],
|
||||
sub_agents=["browser_worker"],
|
||||
)
|
||||
|
||||
spy_llm = MockStreamingLLM(
|
||||
[set_output_scenario("result", "scraped"), text_finish_scenario()]
|
||||
)
|
||||
|
||||
browser_tool = Tool(name="browser_snapshot", description="Snapshot")
|
||||
|
||||
node = EventLoopNode(config=LoopConfig(max_iterations=5))
|
||||
memory = SharedMemory()
|
||||
scoped = memory.with_permissions(read_keys=[], write_keys=["result"])
|
||||
|
||||
ctx = NodeContext(
|
||||
runtime=runtime,
|
||||
node_id="parent",
|
||||
node_spec=parent_spec,
|
||||
memory=scoped,
|
||||
input_data={},
|
||||
llm=spy_llm,
|
||||
available_tools=[],
|
||||
all_tools=[browser_tool],
|
||||
goal_context="",
|
||||
goal=None,
|
||||
node_registry={"browser_worker": gcu_spec},
|
||||
)
|
||||
|
||||
result = await node._execute_subagent(ctx, "browser_worker", "Scrape example.com")
|
||||
assert result.is_error is False
|
||||
|
||||
# Verify subagent LLM received browser tools from catalog
|
||||
assert spy_llm.stream_calls, "LLM should have been called"
|
||||
first_call_tools = spy_llm.stream_calls[0]["tools"]
|
||||
tool_names = {t.name for t in first_call_tools} if first_call_tools else set()
|
||||
assert "browser_snapshot" in tool_names
|
||||
assert "delegate_to_sub_agent" not in tool_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for nested subagent prevention
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Draft Flowchart System — Complete Reference
|
||||
|
||||
The draft flowchart system bridges user-facing workflow design (planning phase) and the runtime agent graph (execution phase). During planning, the queen agent creates an ISO 5807 flowchart that the user reviews. On approval, decision nodes are dissolved into runtime-compatible structures, and the original flowchart is preserved for live status overlay during execution.
|
||||
The draft flowchart system bridges user-facing workflow design (planning phase) and the runtime agent graph (execution phase). During planning, the queen agent creates a flowchart that the user reviews. On approval, decision nodes are dissolved into runtime-compatible structures, and the original flowchart is preserved for live status overlay during execution.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,14 +20,15 @@ DraftGraph (SSE) ────► │ Decision diamonds │
|
||||
│ │ merged into │ Flowchart Map
|
||||
▼ │ predecessor criteria │ inverts to
|
||||
Frontend renders │ │ overlay status
|
||||
ISO 5807 flowchart │ Original draft │ on original
|
||||
Flowchart with │ Original draft │ on original
|
||||
with diamond │ preserved │ flowchart
|
||||
decisions │ │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
- Backend: `core/framework/tools/queen_lifecycle_tools.py` — draft creation, classification, dissolution
|
||||
- Backend: `core/framework/tools/queen_lifecycle_tools.py` — draft creation, dissolution
|
||||
- Backend: `core/framework/tools/flowchart_utils.py` — type definitions, classification, persistence
|
||||
- Backend: `core/framework/server/routes_graphs.py` — REST endpoints
|
||||
- Frontend: `core/frontend/src/components/DraftGraph.tsx` — SVG flowchart renderer
|
||||
- Frontend: `core/frontend/src/api/types.ts` — TypeScript interfaces
|
||||
@@ -114,17 +115,9 @@ decisions │ │
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"start", "terminal", "process", "decision",
|
||||
"io", "document", "multi_document",
|
||||
"subprocess", "preparation",
|
||||
"manual_input", "manual_operation",
|
||||
"delay", "display",
|
||||
"database", "stored_data", "internal_storage",
|
||||
"connector", "offpage_connector",
|
||||
"merge", "extract", "sort", "collate",
|
||||
"summing_junction", "or",
|
||||
"browser", "comment", "alternate_process"
|
||||
"io", "document", "database", "subprocess", "browser"
|
||||
],
|
||||
"description": "ISO 5807 flowchart symbol. Auto-detected if omitted."
|
||||
"description": "Flowchart symbol type. Auto-detected if omitted."
|
||||
},
|
||||
"tools": {
|
||||
"type": "array",
|
||||
@@ -213,7 +206,7 @@ After `save_agent_draft` processes the input, it stores and emits an enriched dr
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "start",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#4CAF50"
|
||||
"flowchart_color": "#8aad3f"
|
||||
},
|
||||
{
|
||||
"id": "check-tier",
|
||||
@@ -223,7 +216,7 @@ After `save_agent_draft` processes the input, it stores and emits an enriched dr
|
||||
"decision_clause": "Is lead score > 80?",
|
||||
"flowchart_type": "decision",
|
||||
"flowchart_shape": "diamond",
|
||||
"flowchart_color": "#FF9800"
|
||||
"flowchart_color": "#d89d26"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
@@ -253,10 +246,10 @@ After `save_agent_draft` processes the input, it stores and emits an enriched dr
|
||||
}
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": { "shape": "stadium", "color": "#4CAF50" },
|
||||
"terminal": { "shape": "stadium", "color": "#F44336" },
|
||||
"process": { "shape": "rectangle", "color": "#2196F3" },
|
||||
"decision": { "shape": "diamond", "color": "#FF9800" }
|
||||
"start": { "shape": "stadium", "color": "#8aad3f" },
|
||||
"terminal": { "shape": "stadium", "color": "#b5453a" },
|
||||
"process": { "shape": "rectangle", "color": "#b5a575" },
|
||||
"decision": { "shape": "diamond", "color": "#d89d26" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -265,7 +258,7 @@ After `save_agent_draft` processes the input, it stores and emits an enriched dr
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `flowchart_type` | `string` | The resolved ISO 5807 symbol type |
|
||||
| `flowchart_type` | `string` | The resolved flowchart symbol type |
|
||||
| `flowchart_shape` | `string` | SVG shape identifier for the frontend renderer |
|
||||
| `flowchart_color` | `string` | Hex color code for the symbol |
|
||||
|
||||
@@ -290,67 +283,27 @@ Returned by `GET /api/sessions/{id}/flowchart-map` after `confirm_and_build()` d
|
||||
|
||||
---
|
||||
|
||||
## 2. ISO 5807 Flowchart Types
|
||||
|
||||
### Core Symbols
|
||||
## 2. Flowchart Types
|
||||
|
||||
| Type | Shape | Color | SVG Primitive | Description |
|
||||
|---|---|---|---|---|
|
||||
| `start` | stadium | `#4CAF50` green | `<rect rx={h/2}>` | Entry point / start terminator |
|
||||
| `terminal` | stadium | `#F44336` red | `<rect rx={h/2}>` | End point / stop terminator |
|
||||
| `process` | rectangle | `#2196F3` blue | `<rect rx={4}>` | General processing step |
|
||||
| `decision` | diamond | `#FF9800` amber | `<polygon>` 4-point | Branching / conditional logic |
|
||||
| `io` | parallelogram | `#9C27B0` purple | `<polygon>` skewed | Data input or output |
|
||||
| `document` | document | `#607D8B` blue-grey | `<path>` wavy bottom | Single document output |
|
||||
| `multi_document` | multi_document | `#78909C` blue-grey | stacked `<rect>` + `<path>` | Multiple documents |
|
||||
| `subprocess` | subroutine | `#009688` teal | `<rect>` + inner `<line>` | Predefined process / sub-agent |
|
||||
| `preparation` | hexagon | `#795548` brown | `<polygon>` 6-point | Setup / initialization step |
|
||||
| `manual_input` | manual_input | `#E91E63` pink | `<polygon>` sloped top | Manual data entry |
|
||||
| `manual_operation` | trapezoid | `#AD1457` dark pink | `<polygon>` tapered bottom | Human-in-the-loop / approval |
|
||||
| `delay` | delay | `#FF5722` deep orange | `<path>` D-shape | Wait / pause / cooldown |
|
||||
| `display` | display | `#00BCD4` cyan | `<path>` pointed left | Display / render output |
|
||||
|
||||
### Data Storage Symbols
|
||||
|
||||
| Type | Shape | Color | SVG Primitive | Description |
|
||||
|---|---|---|---|---|
|
||||
| `database` | cylinder | `#8BC34A` light green | `<path>` + `<ellipse>` top/bottom | Database / direct access storage |
|
||||
| `stored_data` | stored_data | `#CDDC39` lime | `<path>` curved left | Generic data store |
|
||||
| `internal_storage` | internal_storage | `#FFC107` amber | `<rect>` + internal `<line>` grid | Internal memory / cache |
|
||||
|
||||
### Connectors
|
||||
|
||||
| Type | Shape | Color | SVG Primitive | Description |
|
||||
|---|---|---|---|---|
|
||||
| `connector` | circle | `#9E9E9E` grey | `<circle>` | On-page connector |
|
||||
| `offpage_connector` | pentagon | `#757575` dark grey | `<polygon>` 5-point | Off-page connector |
|
||||
|
||||
### Flow Operations
|
||||
|
||||
| Type | Shape | Color | SVG Primitive | Description |
|
||||
|---|---|---|---|---|
|
||||
| `merge` | triangle_inv | `#3F51B5` indigo | `<polygon>` inverted | Merge multiple flows |
|
||||
| `extract` | triangle | `#5C6BC0` indigo light | `<polygon>` upward | Extract / split flow |
|
||||
| `sort` | hourglass | `#7986CB` indigo lighter | `<polygon>` X-shape | Sort operation |
|
||||
| `collate` | hourglass_inv | `#9FA8DA` indigo lightest | `<polygon>` X-shape inv | Collate operation |
|
||||
| `summing_junction` | circle_cross | `#F06292` pink light | `<circle>` + cross `<line>` | Summing junction |
|
||||
| `or` | circle_bar | `#CE93D8` purple light | `<circle>` + plus `<line>` | Logical OR |
|
||||
|
||||
### Domain-Specific (Hive)
|
||||
|
||||
| Type | Shape | Color | SVG Primitive | Description |
|
||||
|---|---|---|---|---|
|
||||
| `browser` | hexagon | `#1A237E` dark indigo | `<polygon>` 6-point | Browser automation (GCU node) |
|
||||
| `comment` | flag | `#BDBDBD` light grey | `<path>` notched right | Annotation / comment |
|
||||
| `alternate_process` | rounded_rect | `#42A5F5` light blue | `<rect rx={12}>` | Alternate process variant |
|
||||
| `start` | stadium | `#8aad3f` spring pollen | `<rect rx={h/2}>` | Entry point / start terminator |
|
||||
| `terminal` | stadium | `#b5453a` propolis red | `<rect rx={h/2}>` | End point / stop terminator |
|
||||
| `process` | rectangle | `#b5a575` warm wheat | `<rect rx={4}>` | General processing step (default) |
|
||||
| `decision` | diamond | `#d89d26` royal honey | `<polygon>` 4-point | Branching / conditional logic |
|
||||
| `io` | parallelogram | `#d06818` burnt orange | `<polygon>` skewed | Data input or output |
|
||||
| `document` | document | `#c4b830` goldenrod | `<path>` wavy bottom | Document / report generation |
|
||||
| `database` | cylinder | `#508878` sage teal | `<path>` + `<ellipse>` | Database / data store |
|
||||
| `subprocess` | subroutine | `#887a48` propolis gold | `<rect>` + inner `<line>` | Predefined process / sub-agent |
|
||||
| `browser` | hexagon | `#cc8850` honey copper | `<polygon>` 6-point | Browser automation (GCU node) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Auto-Classification Priority
|
||||
|
||||
When `flowchart_type` is omitted from a node, the backend classifies it automatically using this priority (function `_classify_flowchart_node` in `queen_lifecycle_tools.py`):
|
||||
When `flowchart_type` is omitted from a node, the backend classifies it automatically using this priority (function `classify_flowchart_node` in `flowchart_utils.py`):
|
||||
|
||||
1. **Explicit override** — if `flowchart_type` is set and valid, use it
|
||||
1. **Explicit override** — if `flowchart_type` is set and valid, use it (old type names are remapped automatically)
|
||||
2. **Node type** — `gcu` nodes become `browser`
|
||||
3. **Position** — first node becomes `start`
|
||||
4. **Terminal detection** — nodes in `terminal_nodes` (or with no outgoing edges) become `terminal`
|
||||
@@ -359,14 +312,8 @@ When `flowchart_type` is omitted from a node, the backend classifies it automati
|
||||
7. **Tool heuristics** — tool names match known patterns:
|
||||
- DB tools (`query_database`, `sql_query`, `read_table`, etc.) → `database`
|
||||
- Doc tools (`generate_report`, `create_document`, etc.) → `document`
|
||||
- I/O tools (`send_email`, `post_to_slack`, `fetch_url`, etc.) → `io`
|
||||
- Display tools (`serve_file_to_user`, `display_results`) → `display`
|
||||
- I/O tools (`send_email`, `post_to_slack`, `fetch_url`, `display_results`, etc.) → `io`
|
||||
8. **Description keyword heuristics**:
|
||||
- `"manual"`, `"approval"`, `"human review"` → `manual_operation`
|
||||
- `"setup"`, `"prepare"`, `"configure"` → `preparation`
|
||||
- `"wait"`, `"delay"`, `"pause"` → `delay`
|
||||
- `"merge"`, `"combine"`, `"aggregate"` → `merge`
|
||||
- `"display"`, `"show"`, `"render"` → `display`
|
||||
- `"database"`, `"data store"`, `"persist"` → `database`
|
||||
- `"report"`, `"document"`, `"summary"` → `document`
|
||||
- `"deliver"`, `"send"`, `"notify"` → `io`
|
||||
@@ -441,7 +388,7 @@ The runtime Level 2 judge evaluates the decision clause against the node's conve
|
||||
|
||||
An SVG-based flowchart renderer that operates in two modes:
|
||||
|
||||
1. **Planning mode** — renders the draft graph with ISO 5807 shapes during the planning phase
|
||||
1. **Planning mode** — renders the draft graph with flowchart shapes during the planning phase
|
||||
2. **Runtime overlay mode** — renders the original (pre-dissolution) draft with live execution status when `flowchartMap` and `runtimeNodes` props are provided
|
||||
|
||||
#### Props
|
||||
@@ -475,7 +422,7 @@ Constants:
|
||||
|
||||
#### Shape Rendering
|
||||
|
||||
The `FlowchartShape` component renders each ISO 5807 shape as SVG primitives. Each shape receives:
|
||||
The `FlowchartShape` component renders each flowchart shape as SVG primitives. Each shape receives:
|
||||
- `x, y, w, h` — bounding box in SVG units
|
||||
- `color` — the hex color from the flowchart type
|
||||
- `selected` — hover state (increases fill opacity from 18% to 28%, brightens stroke)
|
||||
@@ -535,17 +482,22 @@ const STATUS_COLORS = {
|
||||
|
||||
### Workspace Integration (`workspace.tsx`)
|
||||
|
||||
The workspace conditionally renders `DraftGraph` in three scenarios:
|
||||
The workspace always renders a single `<DraftGraph>` component, selecting the best available draft:
|
||||
|
||||
| Condition | Renders | Panel Width |
|
||||
|---|---|---|
|
||||
| `queenPhase === "planning"` and `draftGraph` exists | `<DraftGraph draft={draftGraph} />` | 500px |
|
||||
| `originalDraft` exists (post-planning) | `<DraftGraph draft={originalDraft} flowchartMap={...} runtimeNodes={...} />` | 500px |
|
||||
| Neither | `<AgentGraph ... />` (runtime pipeline view) | 300px |
|
||||
```tsx
|
||||
<DraftGraph
|
||||
draft={activeAgentState?.originalDraft ?? activeAgentState?.draftGraph ?? null}
|
||||
loading={activeAgentState?.queenPhase === "planning" && !activeAgentState?.draftGraph}
|
||||
flowchartMap={activeAgentState?.flowchartMap ?? undefined}
|
||||
runtimeNodes={currentGraph.nodes}
|
||||
/>
|
||||
```
|
||||
|
||||
The graph panel is user-resizable (drag handle on the right edge, 15%–50% of viewport width, default 30%).
|
||||
|
||||
**State management:**
|
||||
- `draftGraph`: Set by `draft_graph_updated` SSE event during planning; cleared on phase change
|
||||
- `originalDraft` + `flowchartMap`: Fetched from `GET /api/sessions/{id}/flowchart-map` when phase transitions away from planning
|
||||
- `originalDraft` + `flowchartMap`: Fetched from `GET /api/sessions/{id}/flowchart-map` when phase transitions away from planning. For template/legacy agents, `originalDraft` is generated at load time via `generate_fallback_flowchart()`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ Complete setup guide for building and running goal-driven agents with the Aden A
|
||||
```
|
||||
|
||||
> **Note for Windows Users:**
|
||||
> Running the setup script on native Windows shells (PowerShell / Git Bash) may sometimes fail due to Python App Execution Aliases.
|
||||
> It is **strongly recommended to use WSL (Windows Subsystem for Linux)** for a smoother setup experience.
|
||||
> Native Windows is supported via `quickstart.ps1`. Run it in PowerShell 5.1+. Disable "App Execution Aliases" in Windows settings to avoid Python path conflicts.
|
||||
|
||||
This will:
|
||||
|
||||
@@ -25,13 +24,19 @@ This will:
|
||||
|
||||
## Windows Setup
|
||||
|
||||
Windows users should use **WSL (Windows Subsystem for Linux)** to set up and run agents.
|
||||
Native Windows is supported. Run the PowerShell quickstart:
|
||||
|
||||
1. [Install WSL 2](https://learn.microsoft.com/en-us/windows/wsl/install) if you haven't already:
|
||||
```powershell
|
||||
.\quickstart.ps1
|
||||
```
|
||||
|
||||
Alternatively, you can use WSL:
|
||||
|
||||
1. [Install WSL 2](https://learn.microsoft.com/en-us/windows/wsl/install):
|
||||
```powershell
|
||||
wsl --install
|
||||
```
|
||||
2. Open your WSL terminal, clone the repo, and run the quickstart script:
|
||||
2. Open your WSL terminal, clone the repo, and run:
|
||||
```bash
|
||||
./quickstart.sh
|
||||
```
|
||||
@@ -93,7 +98,7 @@ uv run python -c "import litellm; print('✓ litellm OK')"
|
||||
```
|
||||
|
||||
> **Windows Tip:**
|
||||
> On Windows, if the verification commands fail, ensure you are running them in **WSL** or after **disabling Python App Execution Aliases** in Windows Settings → Apps → App Execution Aliases.
|
||||
> If the verification commands fail on Windows, disable "App Execution Aliases" in Windows Settings → Apps → App Execution Aliases.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -108,7 +113,7 @@ uv run python -c "import litellm; print('✓ litellm OK')"
|
||||
- pip (latest version)
|
||||
- 2GB+ RAM
|
||||
- Internet connection (for LLM API calls)
|
||||
- For Windows users: WSL 2 is recommended for full compatibility.
|
||||
- For Windows users: PowerShell 5.1+ (native) or WSL 2.
|
||||
|
||||
### API Keys
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ This guide will help you set up the Aden Agent Framework and build your first ag
|
||||
|
||||
The fastest way to get started:
|
||||
|
||||
**Linux / macOS:**
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/adenhq/hive.git
|
||||
@@ -25,6 +27,22 @@ cd hive
|
||||
uv run python -c "import framework; import aden_tools; print('✓ Setup complete')"
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/adenhq/hive.git
|
||||
cd hive
|
||||
|
||||
# 2. Run automated setup
|
||||
.\quickstart.ps1
|
||||
|
||||
# 3. Verify installation (optional, quickstart.ps1 already verifies)
|
||||
uv run python -c "import framework; import aden_tools; print('Setup complete')"
|
||||
```
|
||||
|
||||
> **Note:** On Windows, running `.\quickstart.ps1` requires PowerShell 5.1+. If you see a "running scripts is disabled" error, run `Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass` first. Alternatively, use WSL — see [environment-setup.md](./environment-setup.md) for details.
|
||||
|
||||
## Building Your First Agent
|
||||
|
||||
Agents are not included by default in a fresh clone.
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "competitive_intel_agent",
|
||||
"goal": "Monitor competitor websites, news sources, and GitHub repositories to produce a structured weekly digest with key insights, detailed findings per competitor, and 30-day trend analysis.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Check multiple source types per competitor (website, news, GitHub)",
|
||||
"All findings structured with competitor, category, update, source, and date",
|
||||
"Uses stored data to compare with previous reports for trend analysis",
|
||||
"User receives a formatted, readable competitive intelligence digest"
|
||||
],
|
||||
"constraints": [
|
||||
"Never fabricate findings, news, or data \u2014 only report what was found",
|
||||
"Every finding must include a source URL",
|
||||
"Prioritize findings from the past 7 days; include up to 30 days"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "intake",
|
||||
"name": "Competitor Intake",
|
||||
"description": "Collect competitor list, focus areas, and report preferences from the user",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"competitors_input"
|
||||
],
|
||||
"output_keys": [
|
||||
"competitors",
|
||||
"focus_areas",
|
||||
"report_frequency",
|
||||
"has_github_competitors"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "start",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#8aad3f"
|
||||
},
|
||||
{
|
||||
"id": "web-scraper",
|
||||
"name": "Website Monitor",
|
||||
"description": "Scrape competitor websites for pricing, features, and announcements",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"web_search",
|
||||
"web_scrape"
|
||||
],
|
||||
"input_keys": [
|
||||
"competitors",
|
||||
"focus_areas"
|
||||
],
|
||||
"output_keys": [
|
||||
"web_findings"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "process",
|
||||
"flowchart_shape": "rectangle",
|
||||
"flowchart_color": "#b5a575"
|
||||
},
|
||||
{
|
||||
"id": "news-search",
|
||||
"name": "News & Press Monitor",
|
||||
"description": "Search for competitor mentions in news, press releases, and industry publications",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"web_search",
|
||||
"web_scrape"
|
||||
],
|
||||
"input_keys": [
|
||||
"competitors",
|
||||
"focus_areas"
|
||||
],
|
||||
"output_keys": [
|
||||
"news_findings"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "decision",
|
||||
"flowchart_shape": "diamond",
|
||||
"flowchart_color": "#d89d26"
|
||||
},
|
||||
{
|
||||
"id": "github-monitor",
|
||||
"name": "GitHub Activity Monitor",
|
||||
"description": "Track public GitHub repository activity for competitors with GitHub presence",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"github_list_repos",
|
||||
"github_get_repo",
|
||||
"github_search_repos"
|
||||
],
|
||||
"input_keys": [
|
||||
"competitors"
|
||||
],
|
||||
"output_keys": [
|
||||
"github_findings"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "process",
|
||||
"flowchart_shape": "rectangle",
|
||||
"flowchart_color": "#b5a575"
|
||||
},
|
||||
{
|
||||
"id": "aggregator",
|
||||
"name": "Data Aggregator",
|
||||
"description": "Combine findings from all sources, deduplicate, and structure for analysis",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"save_data",
|
||||
"load_data",
|
||||
"list_data_files"
|
||||
],
|
||||
"input_keys": [
|
||||
"competitors",
|
||||
"web_findings",
|
||||
"news_findings",
|
||||
"github_findings"
|
||||
],
|
||||
"output_keys": [
|
||||
"aggregated_findings"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "database",
|
||||
"flowchart_shape": "cylinder",
|
||||
"flowchart_color": "#508878"
|
||||
},
|
||||
{
|
||||
"id": "analysis",
|
||||
"name": "Insight Analysis",
|
||||
"description": "Extract key insights, detect trends, and compare with historical data",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"load_data",
|
||||
"save_data",
|
||||
"list_data_files"
|
||||
],
|
||||
"input_keys": [
|
||||
"aggregated_findings",
|
||||
"competitors",
|
||||
"focus_areas"
|
||||
],
|
||||
"output_keys": [
|
||||
"key_highlights",
|
||||
"trend_analysis",
|
||||
"detailed_findings"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "database",
|
||||
"flowchart_shape": "cylinder",
|
||||
"flowchart_color": "#508878"
|
||||
},
|
||||
{
|
||||
"id": "report",
|
||||
"name": "Report Generator",
|
||||
"description": "Generate and deliver the competitive intelligence digest as an HTML report",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"save_data",
|
||||
"load_data",
|
||||
"serve_file_to_user",
|
||||
"list_data_files"
|
||||
],
|
||||
"input_keys": [
|
||||
"key_highlights",
|
||||
"trend_analysis",
|
||||
"detailed_findings",
|
||||
"competitors"
|
||||
],
|
||||
"output_keys": [
|
||||
"delivery_status"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "intake",
|
||||
"target": "web-scraper",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "web-scraper",
|
||||
"target": "news-search",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-2",
|
||||
"source": "news-search",
|
||||
"target": "github-monitor",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-3",
|
||||
"source": "news-search",
|
||||
"target": "aggregator",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-4",
|
||||
"source": "github-monitor",
|
||||
"target": "aggregator",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-5",
|
||||
"source": "aggregator",
|
||||
"target": "analysis",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-6",
|
||||
"source": "analysis",
|
||||
"target": "report",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
}
|
||||
],
|
||||
"entry_node": "intake",
|
||||
"terminal_nodes": [
|
||||
"report"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"intake": [
|
||||
"intake"
|
||||
],
|
||||
"web-scraper": [
|
||||
"web-scraper"
|
||||
],
|
||||
"news-search": [
|
||||
"news-search"
|
||||
],
|
||||
"github-monitor": [
|
||||
"github-monitor"
|
||||
],
|
||||
"aggregator": [
|
||||
"aggregator"
|
||||
],
|
||||
"analysis": [
|
||||
"analysis"
|
||||
],
|
||||
"report": [
|
||||
"report"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "deep_research_agent",
|
||||
"goal": "Research any topic by searching diverse sources, analyzing findings, and producing a cited report \u2014 with user checkpoints to guide direction.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Use multiple diverse, authoritative sources",
|
||||
"Every factual claim in the report cites its source",
|
||||
"User reviews findings before report generation",
|
||||
"Final report answers the original research questions"
|
||||
],
|
||||
"constraints": [
|
||||
"Only include information found in fetched sources",
|
||||
"Every claim must cite its source with a numbered reference",
|
||||
"Present findings to the user before writing the final report"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "intake",
|
||||
"name": "Research Intake",
|
||||
"description": "Discuss the research topic with the user, clarify scope, and confirm direction",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"user_request"
|
||||
],
|
||||
"output_keys": [
|
||||
"research_brief"
|
||||
],
|
||||
"success_criteria": "The research brief is specific and actionable: it states the topic, the key questions to answer, the desired scope, and depth.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "start",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#8aad3f"
|
||||
},
|
||||
{
|
||||
"id": "research",
|
||||
"name": "Research",
|
||||
"description": "Search the web, fetch source content, and compile findings",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"web_search",
|
||||
"web_scrape",
|
||||
"load_data",
|
||||
"save_data",
|
||||
"append_data",
|
||||
"list_data_files"
|
||||
],
|
||||
"input_keys": [
|
||||
"research_brief",
|
||||
"feedback"
|
||||
],
|
||||
"output_keys": [
|
||||
"findings",
|
||||
"sources",
|
||||
"gaps"
|
||||
],
|
||||
"success_criteria": "Findings reference at least 3 distinct sources with URLs. Key claims are substantiated by fetched content, not generated.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "database",
|
||||
"flowchart_shape": "cylinder",
|
||||
"flowchart_color": "#508878"
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"name": "Review Findings",
|
||||
"description": "Present findings to user and decide whether to research more or write the report",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"findings",
|
||||
"sources",
|
||||
"gaps",
|
||||
"research_brief"
|
||||
],
|
||||
"output_keys": [
|
||||
"needs_more_research",
|
||||
"feedback"
|
||||
],
|
||||
"success_criteria": "The user has been presented with findings and has explicitly indicated whether they want more research or are ready for the report.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "decision",
|
||||
"flowchart_shape": "diamond",
|
||||
"flowchart_color": "#d89d26"
|
||||
},
|
||||
{
|
||||
"id": "report",
|
||||
"name": "Write & Deliver Report",
|
||||
"description": "Write a cited HTML report from the findings and present it to the user",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"save_data",
|
||||
"append_data",
|
||||
"serve_file_to_user",
|
||||
"load_data",
|
||||
"list_data_files"
|
||||
],
|
||||
"input_keys": [
|
||||
"findings",
|
||||
"sources",
|
||||
"research_brief"
|
||||
],
|
||||
"output_keys": [
|
||||
"delivery_status",
|
||||
"next_action"
|
||||
],
|
||||
"success_criteria": "An HTML report has been saved, the file link has been presented to the user, and the user has indicated what they want to do next.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "intake",
|
||||
"target": "research",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "research",
|
||||
"target": "review",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-2",
|
||||
"source": "review",
|
||||
"target": "research",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-3",
|
||||
"source": "review",
|
||||
"target": "report",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-4",
|
||||
"source": "report",
|
||||
"target": "research",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-5",
|
||||
"source": "report",
|
||||
"target": "intake",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
}
|
||||
],
|
||||
"entry_node": "intake",
|
||||
"terminal_nodes": [
|
||||
"report"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"intake": [
|
||||
"intake"
|
||||
],
|
||||
"research": [
|
||||
"research"
|
||||
],
|
||||
"review": [
|
||||
"review"
|
||||
],
|
||||
"report": [
|
||||
"report"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "email_inbox_management",
|
||||
"goal": "Manage Gmail inbox emails autonomously using user-defined free-text rules. For every five minutes, fetch inbox emails (configurable batch size, default 100), apply the user's rules to each email, and execute the appropriate Gmail actions \u2014 trash, mark as spam, mark important, mark read/unread, star, draft replies, create/apply custom labels, and more.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Gmail actions are applied correctly to the right emails based on the user's rules",
|
||||
"Produces a summary report showing what was done: how many emails were affected by each action type, with email subjects listed",
|
||||
"All fetched emails up to the configured max are processed and acted upon; none are silently skipped",
|
||||
"Custom labels are created and applied correctly when rules require them"
|
||||
],
|
||||
"constraints": [
|
||||
"Must loop through all inbox emails by paginating with max_emails as page size; no emails should be silently skipped",
|
||||
"Archiving removes from inbox but preserves the email; only explicit trash rules move emails to trash",
|
||||
"Agent creates draft replies but NEVER sends them automatically"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "intake",
|
||||
"name": "Intake",
|
||||
"description": "Receive and validate input parameters: rules and max_emails. Present the interpreted rules back to the user for confirmation.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"gmail_list_labels"
|
||||
],
|
||||
"input_keys": [
|
||||
"rules",
|
||||
"max_emails"
|
||||
],
|
||||
"output_keys": [
|
||||
"rules",
|
||||
"max_emails",
|
||||
"query"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "start",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#8aad3f"
|
||||
},
|
||||
{
|
||||
"id": "fetch-emails",
|
||||
"name": "Fetch Emails",
|
||||
"description": "Fetch one page of emails from Gmail inbox. Returns emails filename and next_page_token for pagination. The graph loops back here if more pages remain.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"bulk_fetch_emails"
|
||||
],
|
||||
"input_keys": [
|
||||
"rules",
|
||||
"max_emails",
|
||||
"next_page_token",
|
||||
"last_processed_timestamp",
|
||||
"query"
|
||||
],
|
||||
"output_keys": [
|
||||
"emails",
|
||||
"next_page_token"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "process",
|
||||
"flowchart_shape": "rectangle",
|
||||
"flowchart_color": "#b5a575"
|
||||
},
|
||||
{
|
||||
"id": "classify-and-act",
|
||||
"name": "Classify and Act",
|
||||
"description": "Apply the user's rules to each email and execute the appropriate Gmail actions.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"gmail_trash_message",
|
||||
"gmail_modify_message",
|
||||
"gmail_batch_modify_messages",
|
||||
"gmail_create_draft",
|
||||
"gmail_create_label",
|
||||
"gmail_list_labels",
|
||||
"load_data",
|
||||
"append_data"
|
||||
],
|
||||
"input_keys": [
|
||||
"rules",
|
||||
"emails"
|
||||
],
|
||||
"output_keys": [
|
||||
"actions_taken"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "decision",
|
||||
"flowchart_shape": "diamond",
|
||||
"flowchart_color": "#d89d26"
|
||||
},
|
||||
{
|
||||
"id": "report",
|
||||
"name": "Report",
|
||||
"description": "Generate a summary report of all actions taken on the emails and present it to the user.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"load_data",
|
||||
"get_current_timestamp"
|
||||
],
|
||||
"input_keys": [
|
||||
"actions_taken",
|
||||
"rules"
|
||||
],
|
||||
"output_keys": [
|
||||
"summary_report",
|
||||
"rules",
|
||||
"last_processed_timestamp"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "intake",
|
||||
"target": "fetch-emails",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "fetch-emails",
|
||||
"target": "classify-and-act",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-2",
|
||||
"source": "classify-and-act",
|
||||
"target": "fetch-emails",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-3",
|
||||
"source": "classify-and-act",
|
||||
"target": "report",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-4",
|
||||
"source": "report",
|
||||
"target": "intake",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
}
|
||||
],
|
||||
"entry_node": "intake",
|
||||
"terminal_nodes": [
|
||||
"report"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"intake": [
|
||||
"intake"
|
||||
],
|
||||
"fetch-emails": [
|
||||
"fetch-emails"
|
||||
],
|
||||
"classify-and-act": [
|
||||
"classify-and-act"
|
||||
],
|
||||
"report": [
|
||||
"report"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "email_reply_agent",
|
||||
"goal": "Filter unreplied emails by user criteria, confirm recipients, send personalized replies.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Accurately finds unreplied emails matching user criteria",
|
||||
"User confirms recipient list before sending",
|
||||
"Replies are personalized based on email content and tone guidance"
|
||||
],
|
||||
"constraints": [
|
||||
"Never send emails without explicit user confirmation; always present recipient list and get approval first",
|
||||
"Process up to 50 emails per batch"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "intake",
|
||||
"name": "Intake",
|
||||
"description": "Gather email filter criteria from user",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"batch_complete",
|
||||
"restart"
|
||||
],
|
||||
"output_keys": [
|
||||
"filter_criteria"
|
||||
],
|
||||
"success_criteria": "Filter criteria is specific enough to search Gmail (sender, subject, date range, or keywords).",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "start",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#8aad3f"
|
||||
},
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search Emails",
|
||||
"description": "Search Gmail for unreplied emails matching filter criteria",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"gmail_list_messages",
|
||||
"gmail_get_message",
|
||||
"gmail_batch_get_messages"
|
||||
],
|
||||
"input_keys": [
|
||||
"filter_criteria"
|
||||
],
|
||||
"output_keys": [
|
||||
"email_list"
|
||||
],
|
||||
"success_criteria": "Found unreplied emails matching criteria with sender, subject, snippet, message_id.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "process",
|
||||
"flowchart_shape": "rectangle",
|
||||
"flowchart_color": "#b5a575"
|
||||
},
|
||||
{
|
||||
"id": "confirm-draft",
|
||||
"name": "Confirm & Reply",
|
||||
"description": "Present emails for confirmation, send personalized replies",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"gmail_reply_email"
|
||||
],
|
||||
"input_keys": [
|
||||
"email_list",
|
||||
"filter_criteria"
|
||||
],
|
||||
"output_keys": [
|
||||
"batch_complete",
|
||||
"restart"
|
||||
],
|
||||
"success_criteria": "User confirmed recipients and personalized replies sent for each.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "intake",
|
||||
"target": "search",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "search",
|
||||
"target": "confirm-draft",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-2",
|
||||
"source": "confirm-draft",
|
||||
"target": "intake",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-3",
|
||||
"source": "confirm-draft",
|
||||
"target": "intake",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
}
|
||||
],
|
||||
"entry_node": "intake",
|
||||
"terminal_nodes": [
|
||||
"confirm-draft"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"intake": [
|
||||
"intake"
|
||||
],
|
||||
"search": [
|
||||
"search"
|
||||
],
|
||||
"confirm-draft": [
|
||||
"confirm-draft"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "job_hunter",
|
||||
"goal": "Analyze a user's resume to identify their strongest role fits, find 10 matching job opportunities, let the user select which to pursue, then generate a resume customization list and cold outreach email for each selected job.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Identifies 2-3 role types that genuinely match the user's experience",
|
||||
"Found jobs align with identified roles and user's background",
|
||||
"Resume changes are specific, actionable, and tailored to each job posting",
|
||||
"Cold emails are personalized, professional, and reference specific company/role details",
|
||||
"User approves outputs without major revisions needed"
|
||||
],
|
||||
"constraints": [
|
||||
"Only suggest roles the user is realistically qualified for - no aspirational stretch roles",
|
||||
"Resume customizations must be truthful - enhance presentation, never fabricate experience",
|
||||
"Cold emails must be professional and not spammy",
|
||||
"Only customize for jobs the user explicitly selects"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "intake",
|
||||
"name": "Intake",
|
||||
"description": "Analyze resume and identify 3-5 strongest role types",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"resume_text"
|
||||
],
|
||||
"output_keys": [
|
||||
"resume_text",
|
||||
"role_analysis"
|
||||
],
|
||||
"success_criteria": "The user's resume has been analyzed and 3-5 target roles identified based on their actual experience.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "start",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#8aad3f"
|
||||
},
|
||||
{
|
||||
"id": "job-search",
|
||||
"name": "Job Search",
|
||||
"description": "Search for 10 jobs matching identified roles by scraping job board sites directly",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"web_scrape"
|
||||
],
|
||||
"input_keys": [
|
||||
"role_analysis"
|
||||
],
|
||||
"output_keys": [
|
||||
"job_listings"
|
||||
],
|
||||
"success_criteria": "10 relevant job listings have been found with complete details including title, company, location, description, and URL.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "process",
|
||||
"flowchart_shape": "rectangle",
|
||||
"flowchart_color": "#b5a575"
|
||||
},
|
||||
{
|
||||
"id": "job-review",
|
||||
"name": "Job Review",
|
||||
"description": "Present all 10 jobs to the user, let them select which to pursue",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"job_listings",
|
||||
"resume_text"
|
||||
],
|
||||
"output_keys": [
|
||||
"selected_jobs"
|
||||
],
|
||||
"success_criteria": "User has reviewed all job listings and explicitly selected which jobs they want to apply to.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "process",
|
||||
"flowchart_shape": "rectangle",
|
||||
"flowchart_color": "#b5a575"
|
||||
},
|
||||
{
|
||||
"id": "customize",
|
||||
"name": "Customize",
|
||||
"description": "For each selected job, generate resume customization list and cold outreach email, create Gmail drafts",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"save_data",
|
||||
"append_data",
|
||||
"serve_file_to_user",
|
||||
"gmail_create_draft"
|
||||
],
|
||||
"input_keys": [
|
||||
"selected_jobs",
|
||||
"resume_text"
|
||||
],
|
||||
"output_keys": [
|
||||
"application_materials"
|
||||
],
|
||||
"success_criteria": "Resume customization list and cold outreach email generated for each selected job, saved as HTML, and Gmail drafts created in user's inbox.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "intake",
|
||||
"target": "job-search",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "job-search",
|
||||
"target": "job-review",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-2",
|
||||
"source": "job-review",
|
||||
"target": "customize",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
}
|
||||
],
|
||||
"entry_node": "intake",
|
||||
"terminal_nodes": [
|
||||
"customize"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"intake": [
|
||||
"intake"
|
||||
],
|
||||
"job-search": [
|
||||
"job-search"
|
||||
],
|
||||
"job-review": [
|
||||
"job-review"
|
||||
],
|
||||
"customize": [
|
||||
"customize"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "local_business_extractor",
|
||||
"goal": "Find local businesses on Maps, extract contacts, and sync to Google Sheets.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Extract business details from Maps",
|
||||
"Sync data to Google Sheets"
|
||||
],
|
||||
"constraints": [
|
||||
"Must verify website presence before scraping"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "map-search-worker",
|
||||
"name": "Maps Browser Worker",
|
||||
"description": "Browser subagent that searches Google Maps and extracts business links.",
|
||||
"node_type": "gcu",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"query"
|
||||
],
|
||||
"output_keys": [
|
||||
"business_list"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "browser",
|
||||
"flowchart_shape": "hexagon",
|
||||
"flowchart_color": "#cc8850"
|
||||
},
|
||||
{
|
||||
"id": "extract-contacts",
|
||||
"name": "Extract Business Details",
|
||||
"description": "Scrapes business websites and Maps for comprehensive business details.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"exa_get_contents",
|
||||
"exa_search"
|
||||
],
|
||||
"input_keys": [
|
||||
"user_request"
|
||||
],
|
||||
"output_keys": [
|
||||
"business_data"
|
||||
],
|
||||
"success_criteria": "Comprehensive business details (reviews, hours, contacts) extracted.",
|
||||
"sub_agents": [
|
||||
"map-search-worker"
|
||||
],
|
||||
"flowchart_type": "subprocess",
|
||||
"flowchart_shape": "subroutine",
|
||||
"flowchart_color": "#887a48"
|
||||
},
|
||||
{
|
||||
"id": "sheets-sync",
|
||||
"name": "Google Sheets Sync",
|
||||
"description": "Appends the extracted business data to a Google Sheets spreadsheet.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"google_sheets_create_spreadsheet",
|
||||
"google_sheets_update_values",
|
||||
"google_sheets_append_values",
|
||||
"google_sheets_get_values"
|
||||
],
|
||||
"input_keys": [
|
||||
"business_data"
|
||||
],
|
||||
"output_keys": [
|
||||
"spreadsheet_id"
|
||||
],
|
||||
"success_criteria": "Data successfully synced to Google Sheets.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "extract-contacts",
|
||||
"target": "sheets-sync",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "sheets-sync",
|
||||
"target": "extract-contacts",
|
||||
"condition": "always",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-subagent-2",
|
||||
"source": "extract-contacts",
|
||||
"target": "map-search-worker",
|
||||
"condition": "always",
|
||||
"description": "sub-agent delegation",
|
||||
"label": "delegate"
|
||||
},
|
||||
{
|
||||
"id": "edge-subagent-3",
|
||||
"source": "map-search-worker",
|
||||
"target": "extract-contacts",
|
||||
"condition": "always",
|
||||
"description": "sub-agent report back",
|
||||
"label": "report"
|
||||
}
|
||||
],
|
||||
"entry_node": "extract-contacts",
|
||||
"terminal_nodes": [
|
||||
"sheets-sync"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"extract-contacts": [
|
||||
"extract-contacts",
|
||||
"map-search-worker"
|
||||
],
|
||||
"sheets-sync": [
|
||||
"sheets-sync"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "meeting_scheduler",
|
||||
"goal": "Check calendar availability, find optimal meeting times, record meetings, and send reminders.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Meeting time found within requested duration",
|
||||
"Meeting recorded in spreadsheet accurately",
|
||||
"Attendee email reminder sent",
|
||||
"User confirms meeting details"
|
||||
],
|
||||
"constraints": [
|
||||
"Must use Google Calendar API for availability check",
|
||||
"Meeting duration must match requested time",
|
||||
"Spreadsheet record must include date, time, attendee, title"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "intake",
|
||||
"name": "Intake",
|
||||
"description": "Gather meeting details from the user",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"attendee_email",
|
||||
"meeting_duration_minutes"
|
||||
],
|
||||
"output_keys": [
|
||||
"attendee_email",
|
||||
"meeting_duration_minutes",
|
||||
"meeting_title"
|
||||
],
|
||||
"success_criteria": "User has provided attendee email, meeting duration, and title.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "start",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#8aad3f"
|
||||
},
|
||||
{
|
||||
"id": "schedule",
|
||||
"name": "Schedule",
|
||||
"description": "Find available time on calendar, book meeting with Google Meet, and log to Google Sheet",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"calendar_check_availability",
|
||||
"calendar_create_event",
|
||||
"calendar_list_events",
|
||||
"google_sheets_create_spreadsheet",
|
||||
"google_sheets_get_spreadsheet",
|
||||
"google_sheets_append_values",
|
||||
"send_email"
|
||||
],
|
||||
"input_keys": [
|
||||
"attendee_email",
|
||||
"meeting_duration_minutes",
|
||||
"meeting_title"
|
||||
],
|
||||
"output_keys": [
|
||||
"meeting_time",
|
||||
"booking_confirmed",
|
||||
"spreadsheet_recorded",
|
||||
"email_sent",
|
||||
"meet_link"
|
||||
],
|
||||
"success_criteria": "Meeting time found, Google Meet created, Google Sheet 'Meeting Scheduler' updated with date/time/attendee/title/meet_link, and confirmation email sent.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "io",
|
||||
"flowchart_shape": "parallelogram",
|
||||
"flowchart_color": "#d06818"
|
||||
},
|
||||
{
|
||||
"id": "confirm",
|
||||
"name": "Confirm",
|
||||
"description": "Present booking confirmation to user with Google Meet link",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"meeting_time",
|
||||
"booking_confirmed",
|
||||
"meet_link"
|
||||
],
|
||||
"output_keys": [
|
||||
"next_action"
|
||||
],
|
||||
"success_criteria": "User has acknowledged the booking and received the Google Meet link.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "intake",
|
||||
"target": "schedule",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "schedule",
|
||||
"target": "confirm",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-2",
|
||||
"source": "confirm",
|
||||
"target": "intake",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
}
|
||||
],
|
||||
"entry_node": "intake",
|
||||
"terminal_nodes": [
|
||||
"confirm"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"intake": [
|
||||
"intake"
|
||||
],
|
||||
"schedule": [
|
||||
"schedule"
|
||||
],
|
||||
"confirm": [
|
||||
"confirm"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "tech_news_reporter",
|
||||
"goal": "Research the latest technology and AI news from the web, summarize key stories, and produce a well-organized report for the user to read.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Finds recent, relevant tech/AI news articles",
|
||||
"Covers diverse topics, not just one story",
|
||||
"Produces a structured, readable report with sections, summaries, and links",
|
||||
"Includes source attribution with URLs for every story",
|
||||
"Delivers the report to the user in a viewable format"
|
||||
],
|
||||
"constraints": [
|
||||
"Never fabricate news stories or URLs",
|
||||
"Always attribute sources with links",
|
||||
"Only include news from the past week"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "intake",
|
||||
"name": "Intake",
|
||||
"description": "Greet the user and ask if they have specific tech/AI topics to focus on, or if they want a general news roundup.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [],
|
||||
"output_keys": [
|
||||
"research_brief"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "start",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#8aad3f"
|
||||
},
|
||||
{
|
||||
"id": "research",
|
||||
"name": "Research",
|
||||
"description": "Scrape well-known tech news sites for recent articles and extract key information including titles, summaries, sources, and topics.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"web_scrape"
|
||||
],
|
||||
"input_keys": [
|
||||
"research_brief"
|
||||
],
|
||||
"output_keys": [
|
||||
"articles_data"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "process",
|
||||
"flowchart_shape": "rectangle",
|
||||
"flowchart_color": "#b5a575"
|
||||
},
|
||||
{
|
||||
"id": "compile-report",
|
||||
"name": "Compile Report",
|
||||
"description": "Organize the researched articles into a structured HTML report, save it, and deliver a clickable link to the user.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"save_data",
|
||||
"append_data",
|
||||
"serve_file_to_user"
|
||||
],
|
||||
"input_keys": [
|
||||
"articles_data"
|
||||
],
|
||||
"output_keys": [
|
||||
"report_file"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "intake",
|
||||
"target": "research",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "research",
|
||||
"target": "compile-report",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
}
|
||||
],
|
||||
"entry_node": "intake",
|
||||
"terminal_nodes": [
|
||||
"compile-report"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"intake": [
|
||||
"intake"
|
||||
],
|
||||
"research": [
|
||||
"research"
|
||||
],
|
||||
"compile-report": [
|
||||
"compile-report"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "twitter_news_agent",
|
||||
"goal": "Achieve an accurate and concise daily news digest based on Twitter feed monitoring.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Navigate and extract tweets from at least 3 handles.",
|
||||
"Provide a summary of the most important stories.",
|
||||
"Maintain a persistent log of daily digests."
|
||||
],
|
||||
"constraints": [
|
||||
"Respect rate limits and ethical web usage."
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "fetch-tweets",
|
||||
"name": "Fetch Tech Tweets",
|
||||
"description": "Browser subagent to navigate to tech news Twitter profiles and extract latest tweets.",
|
||||
"node_type": "gcu",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"twitter_handles"
|
||||
],
|
||||
"output_keys": [
|
||||
"raw_tweets"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "browser",
|
||||
"flowchart_shape": "hexagon",
|
||||
"flowchart_color": "#cc8850"
|
||||
},
|
||||
{
|
||||
"id": "process-news",
|
||||
"name": "Process Tech News",
|
||||
"description": "Analyze and summarize the raw tweets into a daily tech digest.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"save_data",
|
||||
"load_data"
|
||||
],
|
||||
"input_keys": [
|
||||
"user_request",
|
||||
"feedback",
|
||||
"raw_tweets"
|
||||
],
|
||||
"output_keys": [
|
||||
"daily_digest"
|
||||
],
|
||||
"success_criteria": "A high-quality, tech-focused news summary.",
|
||||
"sub_agents": [
|
||||
"fetch-tweets"
|
||||
],
|
||||
"flowchart_type": "subprocess",
|
||||
"flowchart_shape": "subroutine",
|
||||
"flowchart_color": "#887a48"
|
||||
},
|
||||
{
|
||||
"id": "review-digest",
|
||||
"name": "Review Digest",
|
||||
"description": "Present the news digest for user review and approval.",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"daily_digest"
|
||||
],
|
||||
"output_keys": [
|
||||
"status",
|
||||
"feedback"
|
||||
],
|
||||
"success_criteria": "User has reviewed the digest and provided feedback or approval.",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "process-news",
|
||||
"target": "review-digest",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "review-digest",
|
||||
"target": "process-news",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-2",
|
||||
"source": "review-digest",
|
||||
"target": "process-news",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-subagent-3",
|
||||
"source": "process-news",
|
||||
"target": "fetch-tweets",
|
||||
"condition": "always",
|
||||
"description": "sub-agent delegation",
|
||||
"label": "delegate"
|
||||
},
|
||||
{
|
||||
"id": "edge-subagent-4",
|
||||
"source": "fetch-tweets",
|
||||
"target": "process-news",
|
||||
"condition": "always",
|
||||
"description": "sub-agent report back",
|
||||
"label": "report"
|
||||
}
|
||||
],
|
||||
"entry_node": "process-news",
|
||||
"terminal_nodes": [
|
||||
"review-digest"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"process-news": [
|
||||
"process-news",
|
||||
"fetch-tweets"
|
||||
],
|
||||
"review-digest": [
|
||||
"review-digest"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
{
|
||||
"original_draft": {
|
||||
"agent_name": "vulnerability_assessment",
|
||||
"goal": "A passive, OSINT-based website vulnerability assessment agent that accepts a website domain, performs non-intrusive security scanning using purpose-built Python tools, produces letter-grade risk scores (A-F) per category, and delivers a structured vulnerability report with remediation guidance. The user is consulted after scanning to decide whether to investigate further or generate the final report.",
|
||||
"description": "",
|
||||
"success_criteria": [
|
||||
"Overall risk grade (A-F) generated from combined scan results",
|
||||
"At least 5 of 6 security categories scored (SSL/TLS, HTTP Headers, DNS, Network, Technology, Attack Surface)",
|
||||
"At least 3 security findings identified across different categories",
|
||||
"Every finding includes clear, actionable remediation steps a developer can follow",
|
||||
"User is presented findings with risk grades and given checkpoint to continue deeper scanning or generate report"
|
||||
],
|
||||
"constraints": [
|
||||
"Never execute active attacks, send exploit payloads, or perform actions that could trigger WAF/IDS systems. Passive and OSINT-based scanning only \u2014 no nmap, sqlmap, or attack payloads.",
|
||||
"All findings and remediation steps must be written for developers using clear language, not security jargon"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "intake",
|
||||
"name": "Intake",
|
||||
"description": "Collect the target website domain from the user and confirm the scanning scope",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [],
|
||||
"output_keys": [
|
||||
"target_domain"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "start",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#8aad3f"
|
||||
},
|
||||
{
|
||||
"id": "passive-recon",
|
||||
"name": "Passive Reconnaissance",
|
||||
"description": "Run all 6 passive scanning tools against the target domain: SSL/TLS, HTTP headers, DNS security, port scanning, tech stack detection, and subdomain enumeration",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"ssl_tls_scan",
|
||||
"http_headers_scan",
|
||||
"dns_security_scan",
|
||||
"port_scan",
|
||||
"tech_stack_detect",
|
||||
"subdomain_enumerate"
|
||||
],
|
||||
"input_keys": [
|
||||
"target_domain",
|
||||
"feedback"
|
||||
],
|
||||
"output_keys": [
|
||||
"scan_results"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "process",
|
||||
"flowchart_shape": "rectangle",
|
||||
"flowchart_color": "#b5a575"
|
||||
},
|
||||
{
|
||||
"id": "risk-scoring",
|
||||
"name": "Risk Scoring",
|
||||
"description": "Calculate weighted letter grades (A-F) per security category and overall risk score from scan results",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"risk_score"
|
||||
],
|
||||
"input_keys": [
|
||||
"scan_results"
|
||||
],
|
||||
"output_keys": [
|
||||
"risk_report"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "process",
|
||||
"flowchart_shape": "rectangle",
|
||||
"flowchart_color": "#b5a575"
|
||||
},
|
||||
{
|
||||
"id": "findings-review",
|
||||
"name": "Findings Review",
|
||||
"description": "Present risk grades and security findings to the user, ask whether to continue deeper scanning or generate the final report",
|
||||
"node_type": "event_loop",
|
||||
"tools": [],
|
||||
"input_keys": [
|
||||
"scan_results",
|
||||
"risk_report",
|
||||
"target_domain"
|
||||
],
|
||||
"output_keys": [
|
||||
"continue_scanning",
|
||||
"feedback",
|
||||
"all_findings"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "decision",
|
||||
"flowchart_shape": "diamond",
|
||||
"flowchart_color": "#d89d26"
|
||||
},
|
||||
{
|
||||
"id": "final-report",
|
||||
"name": "Risk Dashboard Report",
|
||||
"description": "Generate an HTML risk dashboard with color-coded grades, category breakdown, detailed findings, and remediation steps",
|
||||
"node_type": "event_loop",
|
||||
"tools": [
|
||||
"save_data",
|
||||
"append_data",
|
||||
"serve_file_to_user"
|
||||
],
|
||||
"input_keys": [
|
||||
"all_findings",
|
||||
"risk_report",
|
||||
"target_domain"
|
||||
],
|
||||
"output_keys": [
|
||||
"report_status"
|
||||
],
|
||||
"success_criteria": "",
|
||||
"sub_agents": [],
|
||||
"flowchart_type": "terminal",
|
||||
"flowchart_shape": "stadium",
|
||||
"flowchart_color": "#b5453a"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-0",
|
||||
"source": "intake",
|
||||
"target": "passive-recon",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "passive-recon",
|
||||
"target": "risk-scoring",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-2",
|
||||
"source": "risk-scoring",
|
||||
"target": "findings-review",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-3",
|
||||
"source": "findings-review",
|
||||
"target": "passive-recon",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-4",
|
||||
"source": "findings-review",
|
||||
"target": "final-report",
|
||||
"condition": "conditional",
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"id": "edge-5",
|
||||
"source": "final-report",
|
||||
"target": "intake",
|
||||
"condition": "on_success",
|
||||
"description": "",
|
||||
"label": ""
|
||||
}
|
||||
],
|
||||
"entry_node": "intake",
|
||||
"terminal_nodes": [
|
||||
"final-report"
|
||||
],
|
||||
"flowchart_legend": {
|
||||
"start": {
|
||||
"shape": "stadium",
|
||||
"color": "#8aad3f"
|
||||
},
|
||||
"terminal": {
|
||||
"shape": "stadium",
|
||||
"color": "#b5453a"
|
||||
},
|
||||
"process": {
|
||||
"shape": "rectangle",
|
||||
"color": "#b5a575"
|
||||
},
|
||||
"decision": {
|
||||
"shape": "diamond",
|
||||
"color": "#d89d26"
|
||||
},
|
||||
"io": {
|
||||
"shape": "parallelogram",
|
||||
"color": "#d06818"
|
||||
},
|
||||
"document": {
|
||||
"shape": "document",
|
||||
"color": "#c4b830"
|
||||
},
|
||||
"database": {
|
||||
"shape": "cylinder",
|
||||
"color": "#508878"
|
||||
},
|
||||
"subprocess": {
|
||||
"shape": "subroutine",
|
||||
"color": "#887a48"
|
||||
},
|
||||
"browser": {
|
||||
"shape": "hexagon",
|
||||
"color": "#cc8850"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowchart_map": {
|
||||
"intake": [
|
||||
"intake"
|
||||
],
|
||||
"passive-recon": [
|
||||
"passive-recon"
|
||||
],
|
||||
"risk-scoring": [
|
||||
"risk-scoring"
|
||||
],
|
||||
"findings-review": [
|
||||
"findings-review"
|
||||
],
|
||||
"final-report": [
|
||||
"final-report"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$UvHelperPath = Join-Path $ScriptDir "scripts\uv-discovery.ps1"
|
||||
|
||||
. $UvHelperPath
|
||||
|
||||
# ── Validate project directory ──────────────────────────────────────
|
||||
|
||||
@@ -30,16 +33,12 @@ if (-not (Test-Path (Join-Path $ScriptDir ".venv"))) {
|
||||
|
||||
# ── Ensure uv is available ──────────────────────────────────────────
|
||||
|
||||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
# Check default install location before giving up
|
||||
$uvExe = Join-Path $env:USERPROFILE ".local\bin\uv.exe"
|
||||
if (Test-Path $uvExe) {
|
||||
$env:Path = (Split-Path $uvExe) + ";" + $env:Path
|
||||
} else {
|
||||
Write-Error "uv is not installed. Run .\quickstart.ps1 first."
|
||||
exit 1
|
||||
}
|
||||
$uvInfo = Get-WorkingUvInfo
|
||||
if (-not $uvInfo) {
|
||||
Write-Error "uv is not installed or is not runnable. Run .\quickstart.ps1 first."
|
||||
exit 1
|
||||
}
|
||||
$uvExe = $uvInfo.Path
|
||||
|
||||
# ── Load environment variables from Windows Registry ────────────────
|
||||
# Windows stores User-level env vars in the registry. New terminal
|
||||
@@ -80,4 +79,4 @@ if (-not $env:HIVE_CREDENTIAL_KEY) {
|
||||
# ── Run the Hive CLI ────────────────────────────────────────────────
|
||||
# PYTHONUTF8=1: use UTF-8 for default encoding (fixes charmap decode errors on Windows)
|
||||
$env:PYTHONUTF8 = "1"
|
||||
& uv run hive @args
|
||||
& $uvExe run hive @args
|
||||
|
||||
+157
-56
@@ -18,6 +18,13 @@
|
||||
# Use "Continue" so stderr from external tools (uv, python) does not
|
||||
# terminate the script. Errors are handled via $LASTEXITCODE checks.
|
||||
$ErrorActionPreference = "Continue"
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$UvHelperPath = Join-Path $ScriptDir "scripts\uv-discovery.ps1"
|
||||
|
||||
# Hive LLM router endpoint
|
||||
$HiveLlmEndpoint = "https://api.adenhq.com"
|
||||
|
||||
. $UvHelperPath
|
||||
|
||||
# ============================================================
|
||||
# Colors / helpers
|
||||
@@ -95,7 +102,6 @@ function Prompt-Choice {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Windows Defender Exclusion Functions
|
||||
# ============================================================
|
||||
@@ -276,9 +282,6 @@ function Add-DefenderExclusions {
|
||||
}
|
||||
}
|
||||
|
||||
# Get the directory where this script lives
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
# ============================================================
|
||||
# Banner
|
||||
# ============================================================
|
||||
@@ -352,10 +355,10 @@ Write-Host ""
|
||||
# Check / install uv
|
||||
# ============================================================
|
||||
|
||||
$uvCmd = Get-Command uv -ErrorAction SilentlyContinue
|
||||
$uvInfo = Get-WorkingUvInfo
|
||||
|
||||
# If uv not in PATH, check if it exists in default location
|
||||
if (-not $uvCmd) {
|
||||
if (-not $uvInfo) {
|
||||
$uvDir = Join-Path $env:USERPROFILE ".local\bin"
|
||||
$uvExePath = Join-Path $uvDir "uv.exe"
|
||||
|
||||
@@ -371,16 +374,16 @@ if (-not $uvCmd) {
|
||||
|
||||
# Refresh PATH for current session
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "User") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
$uvCmd = Get-Command uv -ErrorAction SilentlyContinue
|
||||
$uvInfo = Get-WorkingUvInfo
|
||||
|
||||
if ($uvCmd) {
|
||||
if ($uvInfo) {
|
||||
Write-Ok "uv is now in PATH"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# If still not found, install it
|
||||
if (-not $uvCmd) {
|
||||
if (-not $uvInfo) {
|
||||
Write-Warn "uv not found. Installing..."
|
||||
try {
|
||||
# Official uv installer for Windows
|
||||
@@ -397,13 +400,13 @@ if (-not $uvCmd) {
|
||||
|
||||
# Refresh PATH for current session
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "User") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
$uvCmd = Get-Command uv -ErrorAction SilentlyContinue
|
||||
$uvInfo = Get-WorkingUvInfo
|
||||
} catch {
|
||||
Write-Color -Text "Error: uv installation failed" -Color Red
|
||||
Write-Host "Please install uv manually from https://astral.sh/uv/"
|
||||
exit 1
|
||||
}
|
||||
if (-not $uvCmd) {
|
||||
if (-not $uvInfo) {
|
||||
Write-Color -Text "Error: uv not found after installation" -Color Red
|
||||
Write-Host "Please close and reopen PowerShell, then run this script again."
|
||||
Write-Host "Or install uv manually from https://astral.sh/uv/"
|
||||
@@ -412,8 +415,8 @@ if (-not $uvCmd) {
|
||||
Write-Ok "uv installed successfully"
|
||||
}
|
||||
|
||||
$uvVersion = & uv --version
|
||||
Write-Ok "uv detected: $uvVersion"
|
||||
$UvCmd = $uvInfo.Path
|
||||
Write-Ok "uv detected: $($uvInfo.Version)"
|
||||
Write-Host ""
|
||||
|
||||
# Check for Node.js (needed for frontend dashboard)
|
||||
@@ -503,7 +506,7 @@ try {
|
||||
if (Test-Path "pyproject.toml") {
|
||||
Write-Host " Installing workspace packages... " -NoNewline
|
||||
|
||||
$syncOutput = & uv sync 2>&1
|
||||
$syncOutput = & $UvCmd sync 2>&1
|
||||
$syncExitCode = $LASTEXITCODE
|
||||
|
||||
if ($syncExitCode -eq 0) {
|
||||
@@ -518,9 +521,9 @@ try {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for Chrome/Edge (required for GCU browser tools)
|
||||
# Keep browser setup scoped to detecting the system browser used by GCU.
|
||||
Write-Host " Checking for Chrome/Edge browser... " -NoNewline
|
||||
$null = & uv run python -c "from gcu.browser.chrome_finder import find_chrome; assert find_chrome()" 2>&1
|
||||
$null = & $UvCmd run python -c "from gcu.browser.chrome_finder import find_chrome; assert find_chrome()" 2>&1
|
||||
$chromeCheckExit = $LASTEXITCODE
|
||||
if ($chromeCheckExit -eq 0) {
|
||||
Write-Ok "ok"
|
||||
@@ -720,7 +723,7 @@ $imports = @(
|
||||
$modulesToCheck = @("framework", "aden_tools", "litellm")
|
||||
|
||||
try {
|
||||
$checkOutput = & uv run python scripts/check_requirements.py @modulesToCheck 2>&1 | Out-String
|
||||
$checkOutput = & $UvCmd run python scripts/check_requirements.py @modulesToCheck 2>&1 | Out-String
|
||||
$resultJson = $null
|
||||
|
||||
# Try to parse JSON result
|
||||
@@ -764,14 +767,6 @@ if ($importErrors -gt 0) {
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ============================================================
|
||||
# Step 4: Verify Claude Code Skills
|
||||
# ============================================================
|
||||
|
||||
Write-Step -Number "4" -Text "Step 4: Verifying Claude Code skills..."
|
||||
|
||||
# (skills check is informational only, shown in final verification)
|
||||
|
||||
# ============================================================
|
||||
# Provider / model data
|
||||
# ============================================================
|
||||
@@ -911,6 +906,11 @@ $kimiKey = [System.Environment]::GetEnvironmentVariable("KIMI_API_KEY", "User")
|
||||
if (-not $kimiKey) { $kimiKey = $env:KIMI_API_KEY }
|
||||
if ($kimiKey) { $KimiCredDetected = $true }
|
||||
|
||||
$HiveCredDetected = $false
|
||||
$hiveKey = [System.Environment]::GetEnvironmentVariable("HIVE_API_KEY", "User")
|
||||
if (-not $hiveKey) { $hiveKey = $env:HIVE_API_KEY }
|
||||
if ($hiveKey) { $HiveCredDetected = $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")
|
||||
@@ -941,6 +941,7 @@ if (Test-Path $HiveConfigFile) {
|
||||
elseif ($prevLlm.use_kimi_code_subscription) { $PrevSubMode = "kimi_code" }
|
||||
elseif ($prevLlm.api_base -and $prevLlm.api_base -like "*api.z.ai*") { $PrevSubMode = "zai_code" }
|
||||
elseif ($prevLlm.api_base -and $prevLlm.api_base -like "*api.kimi.com*") { $PrevSubMode = "kimi_code" }
|
||||
elseif ($prevLlm.provider -eq "hive" -or ($prevLlm.api_base -and $prevLlm.api_base -like "*adenhq.com*")) { $PrevSubMode = "hive_llm" }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
@@ -954,6 +955,7 @@ if ($PrevSubMode -or $PrevProvider) {
|
||||
"zai_code" { if ($ZaiCredDetected) { $prevCredValid = $true } }
|
||||
"codex" { if ($CodexCredDetected) { $prevCredValid = $true } }
|
||||
"kimi_code" { if ($KimiCredDetected) { $prevCredValid = $true } }
|
||||
"hive_llm" { if ($HiveCredDetected) { $prevCredValid = $true } }
|
||||
default {
|
||||
if ($PrevEnvVar) {
|
||||
$envVal = [System.Environment]::GetEnvironmentVariable($PrevEnvVar, "Process")
|
||||
@@ -968,14 +970,15 @@ if ($PrevSubMode -or $PrevProvider) {
|
||||
"zai_code" { $DefaultChoice = "2" }
|
||||
"codex" { $DefaultChoice = "3" }
|
||||
"kimi_code" { $DefaultChoice = "4" }
|
||||
"hive_llm" { $DefaultChoice = "5" }
|
||||
}
|
||||
if (-not $DefaultChoice) {
|
||||
switch ($PrevProvider) {
|
||||
"anthropic" { $DefaultChoice = "5" }
|
||||
"openai" { $DefaultChoice = "6" }
|
||||
"gemini" { $DefaultChoice = "7" }
|
||||
"groq" { $DefaultChoice = "8" }
|
||||
"cerebras" { $DefaultChoice = "9" }
|
||||
"anthropic" { $DefaultChoice = "6" }
|
||||
"openai" { $DefaultChoice = "7" }
|
||||
"gemini" { $DefaultChoice = "8" }
|
||||
"groq" { $DefaultChoice = "9" }
|
||||
"cerebras" { $DefaultChoice = "10" }
|
||||
"kimi" { $DefaultChoice = "4" }
|
||||
}
|
||||
}
|
||||
@@ -1015,12 +1018,19 @@ Write-Host ") Kimi Code Subscription " -NoNewline
|
||||
Write-Color -Text "(use your Kimi Code plan)" -Color DarkGray -NoNewline
|
||||
if ($KimiCredDetected) { Write-Color -Text " (credential detected)" -Color Green } else { Write-Host "" }
|
||||
|
||||
# 5) Hive LLM
|
||||
Write-Host " " -NoNewline
|
||||
Write-Color -Text "5" -Color Cyan -NoNewline
|
||||
Write-Host ") Hive LLM " -NoNewline
|
||||
Write-Color -Text "(use your Hive API key)" -Color DarkGray -NoNewline
|
||||
if ($HiveCredDetected) { Write-Color -Text " (credential detected)" -Color Green } else { Write-Host "" }
|
||||
|
||||
Write-Host ""
|
||||
Write-Color -Text " API key providers:" -Color Cyan
|
||||
|
||||
# 5-9) API key providers
|
||||
# 6-10) API key providers
|
||||
for ($idx = 0; $idx -lt $ProviderMenuEnvVars.Count; $idx++) {
|
||||
$num = $idx + 5
|
||||
$num = $idx + 6
|
||||
$envVal = [System.Environment]::GetEnvironmentVariable($ProviderMenuEnvVars[$idx], "Process")
|
||||
if (-not $envVal) { $envVal = [System.Environment]::GetEnvironmentVariable($ProviderMenuEnvVars[$idx], "User") }
|
||||
Write-Host " " -NoNewline
|
||||
@@ -1030,7 +1040,7 @@ for ($idx = 0; $idx -lt $ProviderMenuEnvVars.Count; $idx++) {
|
||||
}
|
||||
|
||||
Write-Host " " -NoNewline
|
||||
Write-Color -Text "10" -Color Cyan -NoNewline
|
||||
Write-Color -Text "11" -Color Cyan -NoNewline
|
||||
Write-Host ") Skip for now"
|
||||
Write-Host ""
|
||||
|
||||
@@ -1041,16 +1051,16 @@ if ($DefaultChoice) {
|
||||
|
||||
while ($true) {
|
||||
if ($DefaultChoice) {
|
||||
$raw = Read-Host "Enter choice (1-10) [$DefaultChoice]"
|
||||
$raw = Read-Host "Enter choice (1-11) [$DefaultChoice]"
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) { $raw = $DefaultChoice }
|
||||
} else {
|
||||
$raw = Read-Host "Enter choice (1-10)"
|
||||
$raw = Read-Host "Enter choice (1-11)"
|
||||
}
|
||||
if ($raw -match '^\d+$') {
|
||||
$num = [int]$raw
|
||||
if ($num -ge 1 -and $num -le 10) { break }
|
||||
if ($num -ge 1 -and $num -le 11) { break }
|
||||
}
|
||||
Write-Color -Text "Invalid choice. Please enter 1-10" -Color Red
|
||||
Write-Color -Text "Invalid choice. Please enter 1-11" -Color Red
|
||||
}
|
||||
|
||||
switch ($num) {
|
||||
@@ -1091,7 +1101,7 @@ switch ($num) {
|
||||
Write-Warn "Codex credentials not found. Starting OAuth login..."
|
||||
Write-Host ""
|
||||
try {
|
||||
& uv run python (Join-Path $ScriptDir "core\codex_oauth.py") 2>&1
|
||||
& $UvCmd run python (Join-Path $ScriptDir "core\codex_oauth.py") 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$CodexCredDetected = $true
|
||||
} else {
|
||||
@@ -1129,9 +1139,33 @@ switch ($num) {
|
||||
Write-Ok "Using Kimi Code subscription"
|
||||
Write-Color -Text " Model: kimi-k2.5 | API: api.kimi.com/coding" -Color DarkGray
|
||||
}
|
||||
{ $_ -ge 5 -and $_ -le 9 } {
|
||||
5 {
|
||||
# Hive LLM
|
||||
$SubscriptionMode = "hive_llm"
|
||||
$SelectedProviderId = "hive"
|
||||
$SelectedEnvVar = "HIVE_API_KEY"
|
||||
$SelectedMaxTokens = 32768
|
||||
$SelectedMaxContextTokens = 120000
|
||||
Write-Host ""
|
||||
Write-Ok "Using Hive LLM"
|
||||
Write-Host ""
|
||||
Write-Host " Select a model:"
|
||||
Write-Host " " -NoNewline; Write-Color -Text "1)" -Color Cyan -NoNewline; Write-Host " queen " -NoNewline; Write-Color -Text "(default - Hive flagship)" -Color DarkGray
|
||||
Write-Host " " -NoNewline; Write-Color -Text "2)" -Color Cyan -NoNewline; Write-Host " kimi-2.5"
|
||||
Write-Host " " -NoNewline; Write-Color -Text "3)" -Color Cyan -NoNewline; Write-Host " GLM-5"
|
||||
Write-Host ""
|
||||
$hiveModelChoice = Read-Host " Enter model choice (1-3) [1]"
|
||||
if (-not $hiveModelChoice) { $hiveModelChoice = "1" }
|
||||
switch ($hiveModelChoice) {
|
||||
"2" { $SelectedModel = "kimi-2.5" }
|
||||
"3" { $SelectedModel = "GLM-5" }
|
||||
default { $SelectedModel = "queen" }
|
||||
}
|
||||
Write-Color -Text " Model: $SelectedModel | API: $HiveLlmEndpoint" -Color DarkGray
|
||||
}
|
||||
{ $_ -ge 6 -and $_ -le 10 } {
|
||||
# API key providers
|
||||
$provIdx = $num - 5
|
||||
$provIdx = $num - 6
|
||||
$SelectedEnvVar = $ProviderMenuEnvVars[$provIdx]
|
||||
$SelectedProviderId = $ProviderMenuIds[$provIdx]
|
||||
$providerName = $ProviderMenuNames[$provIdx] -replace ' - .*', '' # strip description
|
||||
@@ -1164,7 +1198,7 @@ switch ($num) {
|
||||
# Health check the new key
|
||||
Write-Host " Verifying API key... " -NoNewline
|
||||
try {
|
||||
$hcResult = & uv run python (Join-Path $ScriptDir "scripts/check_llm_key.py") $SelectedProviderId $apiKey 2>$null
|
||||
$hcResult = & $UvCmd run python (Join-Path $ScriptDir "scripts/check_llm_key.py") $SelectedProviderId $apiKey 2>$null
|
||||
$hcJson = $hcResult | ConvertFrom-Json
|
||||
if ($hcJson.valid -eq $true) {
|
||||
Write-Color -Text "ok" -Color Green
|
||||
@@ -1202,7 +1236,7 @@ switch ($num) {
|
||||
}
|
||||
}
|
||||
}
|
||||
10 {
|
||||
11 {
|
||||
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:"
|
||||
@@ -1239,7 +1273,7 @@ if ($SubscriptionMode -eq "zai_code") {
|
||||
# Health check the new key
|
||||
Write-Host " Verifying ZAI API key... " -NoNewline
|
||||
try {
|
||||
$hcResult = & uv run python (Join-Path $ScriptDir "scripts/check_llm_key.py") "zai" $apiKey "https://api.z.ai/api/coding/paas/v4" 2>$null
|
||||
$hcResult = & $UvCmd run python (Join-Path $ScriptDir "scripts/check_llm_key.py") "zai" $apiKey "https://api.z.ai/api/coding/paas/v4" 2>$null
|
||||
$hcJson = $hcResult | ConvertFrom-Json
|
||||
if ($hcJson.valid -eq $true) {
|
||||
Write-Color -Text "ok" -Color Green
|
||||
@@ -1307,7 +1341,7 @@ if ($SubscriptionMode -eq "kimi_code") {
|
||||
# Health check the new key
|
||||
Write-Host " Verifying Kimi API key... " -NoNewline
|
||||
try {
|
||||
$hcResult = & uv run python (Join-Path $ScriptDir "scripts/check_llm_key.py") "kimi" $apiKey "https://api.kimi.com/coding" 2>$null
|
||||
$hcResult = & $UvCmd run python (Join-Path $ScriptDir "scripts/check_llm_key.py") "kimi" $apiKey "https://api.kimi.com/coding" 2>$null
|
||||
$hcJson = $hcResult | ConvertFrom-Json
|
||||
if ($hcJson.valid -eq $true) {
|
||||
Write-Color -Text "ok" -Color Green
|
||||
@@ -1343,6 +1377,70 @@ if ($SubscriptionMode -eq "kimi_code") {
|
||||
}
|
||||
}
|
||||
|
||||
# For Hive LLM: prompt for API key with verification + retry
|
||||
if ($SubscriptionMode -eq "hive_llm") {
|
||||
while ($true) {
|
||||
$existingHive = [System.Environment]::GetEnvironmentVariable("HIVE_API_KEY", "User")
|
||||
if (-not $existingHive) { $existingHive = $env:HIVE_API_KEY }
|
||||
|
||||
if ($existingHive) {
|
||||
$masked = $existingHive.Substring(0, [Math]::Min(4, $existingHive.Length)) + "..." + $existingHive.Substring([Math]::Max(0, $existingHive.Length - 4))
|
||||
Write-Host ""
|
||||
Write-Color -Text " $([char]0x2B22) Current Hive key: $masked" -Color Green
|
||||
Write-Host ""
|
||||
$apiKey = Read-Host "Paste a new Hive API key (or press Enter to keep current)"
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host " Get your API key from: " -NoNewline
|
||||
Write-Color -Text "https://discord.com/invite/hQdU7QDkgR" -Color Cyan
|
||||
Write-Host ""
|
||||
$apiKey = Read-Host "Paste your Hive API key (or press Enter to skip)"
|
||||
}
|
||||
|
||||
if ($apiKey) {
|
||||
[System.Environment]::SetEnvironmentVariable("HIVE_API_KEY", $apiKey, "User")
|
||||
$env:HIVE_API_KEY = $apiKey
|
||||
Write-Host ""
|
||||
Write-Ok "Hive API key saved as User environment variable"
|
||||
|
||||
# Health check the new key
|
||||
Write-Host " Verifying Hive API key... " -NoNewline
|
||||
try {
|
||||
$hcOutput = & $PythonCmd scripts/check_llm_key.py hive $apiKey "$HiveLlmEndpoint" 2>&1
|
||||
$hcJson = $hcOutput | ConvertFrom-Json
|
||||
if ($hcJson.valid -eq $true) {
|
||||
Write-Color -Text "ok" -Color Green
|
||||
break
|
||||
} elseif ($hcJson.valid -eq $false) {
|
||||
Write-Color -Text "failed" -Color Red
|
||||
Write-Warn $hcJson.message
|
||||
[System.Environment]::SetEnvironmentVariable("HIVE_API_KEY", $null, "User")
|
||||
Remove-Item -Path "Env:\HIVE_API_KEY" -ErrorAction SilentlyContinue
|
||||
Write-Host ""
|
||||
Read-Host " Press Enter to try again"
|
||||
} else {
|
||||
Write-Color -Text "--" -Color Yellow
|
||||
Write-Color -Text " Could not verify key (network issue). The key has been saved." -Color DarkGray
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
Write-Color -Text "--" -Color Yellow
|
||||
break
|
||||
}
|
||||
} elseif (-not $existingHive) {
|
||||
Write-Host ""
|
||||
Write-Warn "Skipped. Add your Hive API key later:"
|
||||
Write-Color -Text " [System.Environment]::SetEnvironmentVariable('HIVE_API_KEY', 'your-key', 'User')" -Color Cyan
|
||||
$SelectedEnvVar = ""
|
||||
$SelectedProviderId = ""
|
||||
$SubscriptionMode = ""
|
||||
break
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Prompt for model if not already selected (manual provider path)
|
||||
if ($SelectedProviderId -and -not $SelectedModel) {
|
||||
$modelSel = Get-ModelSelection $SelectedProviderId
|
||||
@@ -1383,6 +1481,9 @@ if ($SelectedProviderId) {
|
||||
} elseif ($SubscriptionMode -eq "kimi_code") {
|
||||
$config.llm["api_base"] = "https://api.kimi.com/coding"
|
||||
$config.llm["api_key_env_var"] = $SelectedEnvVar
|
||||
} elseif ($SubscriptionMode -eq "hive_llm") {
|
||||
$config.llm["api_base"] = $HiveLlmEndpoint
|
||||
$config.llm["api_key_env_var"] = $SelectedEnvVar
|
||||
} else {
|
||||
$config.llm["api_key_env_var"] = $SelectedEnvVar
|
||||
}
|
||||
@@ -1394,7 +1495,7 @@ if ($SelectedProviderId) {
|
||||
Write-Host ""
|
||||
|
||||
# ============================================================
|
||||
# Step 5b: Browser Automation (GCU) — always enabled
|
||||
# Browser Automation (GCU) — always enabled
|
||||
# ============================================================
|
||||
|
||||
Write-Host ""
|
||||
@@ -1419,10 +1520,10 @@ if (Test-Path $HiveConfigFile) {
|
||||
Write-Host ""
|
||||
|
||||
# ============================================================
|
||||
# Step 6: Initialize Credential Store
|
||||
# Step 4: Initialize Credential Store
|
||||
# ============================================================
|
||||
|
||||
Write-Step -Number "5" -Text "Step 5: Initializing credential store..."
|
||||
Write-Step -Number "4" -Text "Step 4: Initializing credential store..."
|
||||
Write-Color -Text "The credential store encrypts API keys and secrets for your agents." -Color DarkGray
|
||||
Write-Host ""
|
||||
|
||||
@@ -1459,7 +1560,7 @@ if ($credKey) {
|
||||
} 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
|
||||
$generatedKey = & $UvCmd run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $generatedKey) {
|
||||
Write-Ok "ok"
|
||||
$generatedKey = $generatedKey.Trim()
|
||||
@@ -1508,7 +1609,7 @@ if ($credKey) {
|
||||
Write-Ok "Credential store initialized at ~/.hive/credentials/"
|
||||
|
||||
Write-Host " Verifying credential store... " -NoNewline
|
||||
$verifyOut = & uv run python -c "from framework.credentials.storage import EncryptedFileStorage; storage = EncryptedFileStorage(); print('ok')" 2>$null
|
||||
$verifyOut = & $UvCmd run python -c "from framework.credentials.storage import EncryptedFileStorage; storage = EncryptedFileStorage(); print('ok')" 2>$null
|
||||
if ($verifyOut -match "ok") {
|
||||
Write-Ok "ok"
|
||||
} else {
|
||||
@@ -1518,10 +1619,10 @@ if ($credKey) {
|
||||
Write-Host ""
|
||||
|
||||
# ============================================================
|
||||
# Step 6: Verify Setup
|
||||
# Step 5: Verify Setup
|
||||
# ============================================================
|
||||
|
||||
Write-Step -Number "6" -Text "Step 6: Verifying installation..."
|
||||
Write-Step -Number "5" -Text "Step 5: Verifying installation..."
|
||||
|
||||
$verifyErrors = 0
|
||||
|
||||
@@ -1529,7 +1630,7 @@ $verifyErrors = 0
|
||||
$verifyModules = @("framework", "aden_tools")
|
||||
|
||||
try {
|
||||
$verifyOutput = & uv run python scripts/check_requirements.py @verifyModules 2>&1 | Out-String
|
||||
$verifyOutput = & $UvCmd run python scripts/check_requirements.py @verifyModules 2>&1 | Out-String
|
||||
$verifyJson = $null
|
||||
|
||||
try {
|
||||
@@ -1539,7 +1640,7 @@ try {
|
||||
# Fall back to basic checks if JSON parsing fails
|
||||
foreach ($mod in $verifyModules) {
|
||||
Write-Host " $([char]0x2B21) $mod... " -NoNewline
|
||||
$null = & uv run python -c "import $mod" 2>&1
|
||||
$null = & $UvCmd run python -c "import $mod" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { Write-Ok "ok" }
|
||||
else { Write-Fail "failed"; $verifyErrors++ }
|
||||
}
|
||||
@@ -1559,7 +1660,7 @@ try {
|
||||
}
|
||||
|
||||
Write-Host " $([char]0x2B21) litellm... " -NoNewline
|
||||
$null = & uv run python -c "import litellm" 2>&1
|
||||
$null = & $UvCmd run python -c "import litellm" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { Write-Ok "ok" } else { Write-Warn "skipped" }
|
||||
|
||||
Write-Host " $([char]0x2B21) MCP config... " -NoNewline
|
||||
@@ -1625,10 +1726,10 @@ if ($verifyErrors -gt 0) {
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Step 7: Install hive CLI wrapper
|
||||
# Step 6: Install hive CLI wrapper
|
||||
# ============================================================
|
||||
|
||||
Write-Step -Number "7" -Text "Step 7: Installing hive CLI..."
|
||||
Write-Step -Number "6" -Text "Step 6: Installing hive CLI..."
|
||||
|
||||
# Verify hive.ps1 wrapper exists in project root
|
||||
$hivePs1Path = Join-Path $ScriptDir "hive.ps1"
|
||||
|
||||
+75
-41
@@ -32,6 +32,9 @@ NC='\033[0m' # No Color
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
# Hive LLM router endpoint
|
||||
HIVE_LLM_ENDPOINT="https://api.adenhq.com"
|
||||
|
||||
# Helper function for prompts
|
||||
prompt_yes_no() {
|
||||
local prompt="$1"
|
||||
@@ -300,18 +303,11 @@ if [ "$NODE_AVAILABLE" = true ]; then
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Step 3: Configure LLM API Key
|
||||
# ============================================================
|
||||
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Step 3: Configuring LLM provider...${NC}"
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# Step 3: Verify Python Imports
|
||||
# ============================================================
|
||||
|
||||
echo -e "${BLUE}Step 3: Verifying Python imports...${NC}"
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Step 3: Verifying Python imports...${NC}"
|
||||
echo ""
|
||||
|
||||
IMPORT_ERRORS=0
|
||||
@@ -367,13 +363,6 @@ fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# Step 4: Verify Claude Code Skills
|
||||
# ============================================================
|
||||
|
||||
echo -e "${BLUE}Step 4: Verifying Claude Code skills...${NC}"
|
||||
echo ""
|
||||
|
||||
# Provider configuration - use associative arrays (Bash 4+) or indexed arrays (Bash 3.2)
|
||||
if [ "$USE_ASSOC_ARRAYS" = true ]; then
|
||||
# Bash 4+ - use associative arrays (cleaner and more efficient)
|
||||
@@ -878,6 +867,11 @@ elif [ -n "${KIMI_API_KEY:-}" ]; then
|
||||
KIMI_CRED_DETECTED=true
|
||||
fi
|
||||
|
||||
HIVE_CRED_DETECTED=false
|
||||
if [ -n "${HIVE_API_KEY:-}" ]; then
|
||||
HIVE_CRED_DETECTED=true
|
||||
fi
|
||||
|
||||
# Detect API key providers
|
||||
if [ "$USE_ASSOC_ARRAYS" = true ]; then
|
||||
for env_var in "${!PROVIDER_NAMES[@]}"; do
|
||||
@@ -915,6 +909,7 @@ try:
|
||||
elif llm.get('use_codex_subscription'): sub = 'codex'
|
||||
elif llm.get('use_kimi_code_subscription'): sub = 'kimi_code'
|
||||
elif llm.get('provider', '') == 'minimax' or 'api.minimax.io' in llm.get('api_base', ''): sub = 'minimax_code'
|
||||
elif llm.get('provider', '') == 'hive' or 'adenhq.com' in llm.get('api_base', ''): sub = 'hive_llm'
|
||||
elif 'api.z.ai' in llm.get('api_base', ''): sub = 'zai_code'
|
||||
print(f'PREV_SUB_MODE={sub}')
|
||||
except Exception:
|
||||
@@ -931,6 +926,7 @@ if [ -n "$PREV_SUB_MODE" ] || [ -n "$PREV_PROVIDER" ]; then
|
||||
zai_code) [ "$ZAI_CRED_DETECTED" = true ] && PREV_CRED_VALID=true ;;
|
||||
codex) [ "$CODEX_CRED_DETECTED" = true ] && PREV_CRED_VALID=true ;;
|
||||
kimi_code) [ "$KIMI_CRED_DETECTED" = true ] && PREV_CRED_VALID=true ;;
|
||||
hive_llm) [ "$HIVE_CRED_DETECTED" = true ] && PREV_CRED_VALID=true ;;
|
||||
*)
|
||||
# API key provider — check if the env var is set
|
||||
if [ -n "$PREV_ENV_VAR" ] && [ -n "${!PREV_ENV_VAR}" ]; then
|
||||
@@ -946,16 +942,18 @@ if [ -n "$PREV_SUB_MODE" ] || [ -n "$PREV_PROVIDER" ]; then
|
||||
codex) DEFAULT_CHOICE=3 ;;
|
||||
minimax_code) DEFAULT_CHOICE=4 ;;
|
||||
kimi_code) DEFAULT_CHOICE=5 ;;
|
||||
hive_llm) DEFAULT_CHOICE=6 ;;
|
||||
esac
|
||||
if [ -z "$DEFAULT_CHOICE" ]; then
|
||||
case "$PREV_PROVIDER" in
|
||||
anthropic) DEFAULT_CHOICE=6 ;;
|
||||
openai) DEFAULT_CHOICE=7 ;;
|
||||
gemini) DEFAULT_CHOICE=8 ;;
|
||||
groq) DEFAULT_CHOICE=9 ;;
|
||||
cerebras) DEFAULT_CHOICE=10 ;;
|
||||
anthropic) DEFAULT_CHOICE=7 ;;
|
||||
openai) DEFAULT_CHOICE=8 ;;
|
||||
gemini) DEFAULT_CHOICE=9 ;;
|
||||
groq) DEFAULT_CHOICE=10 ;;
|
||||
cerebras) DEFAULT_CHOICE=11 ;;
|
||||
minimax) DEFAULT_CHOICE=4 ;;
|
||||
kimi) DEFAULT_CHOICE=5 ;;
|
||||
hive) DEFAULT_CHOICE=6 ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
@@ -1001,14 +999,21 @@ else
|
||||
echo -e " ${CYAN}5)${NC} Kimi Code Subscription ${DIM}(use your Kimi Code plan)${NC}"
|
||||
fi
|
||||
|
||||
# 6) Hive LLM
|
||||
if [ "$HIVE_CRED_DETECTED" = true ]; then
|
||||
echo -e " ${CYAN}6)${NC} Hive LLM ${DIM}(use your Hive API key)${NC} ${GREEN}(credential detected)${NC}"
|
||||
else
|
||||
echo -e " ${CYAN}6)${NC} Hive LLM ${DIM}(use your Hive API key)${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e " ${CYAN}${BOLD}API key providers:${NC}"
|
||||
|
||||
# 6-10) API key providers — show (credential detected) if key already set
|
||||
# 7-11) API key providers — show (credential detected) if key already set
|
||||
PROVIDER_MENU_ENVS=(ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY GROQ_API_KEY CEREBRAS_API_KEY)
|
||||
PROVIDER_MENU_NAMES=("Anthropic (Claude) - Recommended" "OpenAI (GPT)" "Google Gemini - Free tier available" "Groq - Fast, free tier" "Cerebras - Fast, free tier")
|
||||
for idx in 0 1 2 3 4; do
|
||||
num=$((idx + 6))
|
||||
num=$((idx + 7))
|
||||
env_var="${PROVIDER_MENU_ENVS[$idx]}"
|
||||
if [ -n "${!env_var}" ]; then
|
||||
echo -e " ${CYAN}$num)${NC} ${PROVIDER_MENU_NAMES[$idx]} ${GREEN}(credential detected)${NC}"
|
||||
@@ -1017,7 +1022,7 @@ for idx in 0 1 2 3 4; do
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e " ${CYAN}11)${NC} Skip for now"
|
||||
echo -e " ${CYAN}12)${NC} Skip for now"
|
||||
echo ""
|
||||
|
||||
if [ -n "$DEFAULT_CHOICE" ]; then
|
||||
@@ -1027,15 +1032,15 @@ fi
|
||||
|
||||
while true; do
|
||||
if [ -n "$DEFAULT_CHOICE" ]; then
|
||||
read -r -p "Enter choice (1-11) [$DEFAULT_CHOICE]: " choice || true
|
||||
read -r -p "Enter choice (1-12) [$DEFAULT_CHOICE]: " choice || true
|
||||
choice="${choice:-$DEFAULT_CHOICE}"
|
||||
else
|
||||
read -r -p "Enter choice (1-11): " choice || true
|
||||
read -r -p "Enter choice (1-12): " choice || true
|
||||
fi
|
||||
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le 11 ]; then
|
||||
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le 12 ]; then
|
||||
break
|
||||
fi
|
||||
echo -e "${RED}Invalid choice. Please enter 1-11${NC}"
|
||||
echo -e "${RED}Invalid choice. Please enter 1-12${NC}"
|
||||
done
|
||||
|
||||
case $choice in
|
||||
@@ -1132,36 +1137,63 @@ case $choice in
|
||||
echo -e " ${DIM}Model: kimi-k2.5 | API: api.kimi.com/coding${NC}"
|
||||
;;
|
||||
6)
|
||||
# Hive LLM
|
||||
SUBSCRIPTION_MODE="hive_llm"
|
||||
SELECTED_PROVIDER_ID="hive"
|
||||
SELECTED_ENV_VAR="HIVE_API_KEY"
|
||||
SELECTED_MAX_TOKENS=32768
|
||||
SELECTED_MAX_CONTEXT_TOKENS=120000
|
||||
SELECTED_API_BASE="$HIVE_LLM_ENDPOINT"
|
||||
PROVIDER_NAME="Hive"
|
||||
SIGNUP_URL="https://discord.com/invite/hQdU7QDkgR"
|
||||
echo ""
|
||||
echo -e "${GREEN}⬢${NC} Using Hive LLM"
|
||||
echo ""
|
||||
echo -e " Select a model:"
|
||||
echo -e " ${CYAN}1)${NC} queen ${DIM}(default — Hive flagship)${NC}"
|
||||
echo -e " ${CYAN}2)${NC} kimi-2.5"
|
||||
echo -e " ${CYAN}3)${NC} GLM-5"
|
||||
echo ""
|
||||
read -r -p " Enter model choice (1-3) [1]: " hive_model_choice || true
|
||||
hive_model_choice="${hive_model_choice:-1}"
|
||||
case "$hive_model_choice" in
|
||||
2) SELECTED_MODEL="kimi-2.5" ;;
|
||||
3) SELECTED_MODEL="GLM-5" ;;
|
||||
*) SELECTED_MODEL="queen" ;;
|
||||
esac
|
||||
echo -e " ${DIM}Model: $SELECTED_MODEL | API: ${HIVE_LLM_ENDPOINT}${NC}"
|
||||
;;
|
||||
7)
|
||||
SELECTED_ENV_VAR="ANTHROPIC_API_KEY"
|
||||
SELECTED_PROVIDER_ID="anthropic"
|
||||
PROVIDER_NAME="Anthropic"
|
||||
SIGNUP_URL="https://console.anthropic.com/settings/keys"
|
||||
;;
|
||||
7)
|
||||
8)
|
||||
SELECTED_ENV_VAR="OPENAI_API_KEY"
|
||||
SELECTED_PROVIDER_ID="openai"
|
||||
PROVIDER_NAME="OpenAI"
|
||||
SIGNUP_URL="https://platform.openai.com/api-keys"
|
||||
;;
|
||||
8)
|
||||
9)
|
||||
SELECTED_ENV_VAR="GEMINI_API_KEY"
|
||||
SELECTED_PROVIDER_ID="gemini"
|
||||
PROVIDER_NAME="Google Gemini"
|
||||
SIGNUP_URL="https://aistudio.google.com/apikey"
|
||||
;;
|
||||
9)
|
||||
10)
|
||||
SELECTED_ENV_VAR="GROQ_API_KEY"
|
||||
SELECTED_PROVIDER_ID="groq"
|
||||
PROVIDER_NAME="Groq"
|
||||
SIGNUP_URL="https://console.groq.com/keys"
|
||||
;;
|
||||
10)
|
||||
11)
|
||||
SELECTED_ENV_VAR="CEREBRAS_API_KEY"
|
||||
SELECTED_PROVIDER_ID="cerebras"
|
||||
PROVIDER_NAME="Cerebras"
|
||||
SIGNUP_URL="https://cloud.cerebras.ai/"
|
||||
;;
|
||||
11)
|
||||
12)
|
||||
echo ""
|
||||
echo -e "${YELLOW}Skipped.${NC} An LLM API key is required to test and use worker agents."
|
||||
echo -e "Add your API key later by running:"
|
||||
@@ -1174,7 +1206,7 @@ case $choice in
|
||||
esac
|
||||
|
||||
# For API-key providers: prompt for key (allow replacement if already set)
|
||||
if { [ -z "$SUBSCRIPTION_MODE" ] || [ "$SUBSCRIPTION_MODE" = "minimax_code" ] || [ "$SUBSCRIPTION_MODE" = "kimi_code" ]; } && [ -n "$SELECTED_ENV_VAR" ]; then
|
||||
if { [ -z "$SUBSCRIPTION_MODE" ] || [ "$SUBSCRIPTION_MODE" = "minimax_code" ] || [ "$SUBSCRIPTION_MODE" = "kimi_code" ] || [ "$SUBSCRIPTION_MODE" = "hive_llm" ]; } && [ -n "$SELECTED_ENV_VAR" ]; then
|
||||
while true; do
|
||||
CURRENT_KEY="${!SELECTED_ENV_VAR}"
|
||||
if [ -n "$CURRENT_KEY" ]; then
|
||||
@@ -1202,7 +1234,7 @@ if { [ -z "$SUBSCRIPTION_MODE" ] || [ "$SUBSCRIPTION_MODE" = "minimax_code" ] ||
|
||||
echo -e "${GREEN}⬢${NC} API key saved to $SHELL_RC_FILE"
|
||||
# Health check the new key
|
||||
echo -n " Verifying API key... "
|
||||
if { [ "$SUBSCRIPTION_MODE" = "minimax_code" ] || [ "$SUBSCRIPTION_MODE" = "kimi_code" ]; } && [ -n "${SELECTED_API_BASE:-}" ]; then
|
||||
if { [ "$SUBSCRIPTION_MODE" = "minimax_code" ] || [ "$SUBSCRIPTION_MODE" = "kimi_code" ] || [ "$SUBSCRIPTION_MODE" = "hive_llm" ]; } && [ -n "${SELECTED_API_BASE:-}" ]; then
|
||||
HC_RESULT=$(uv run python "$SCRIPT_DIR/scripts/check_llm_key.py" "$SELECTED_PROVIDER_ID" "$API_KEY" "$SELECTED_API_BASE" 2>/dev/null) || true
|
||||
else
|
||||
HC_RESULT=$(uv run python "$SCRIPT_DIR/scripts/check_llm_key.py" "$SELECTED_PROVIDER_ID" "$API_KEY" 2>/dev/null) || true
|
||||
@@ -1324,6 +1356,8 @@ if [ -n "$SELECTED_PROVIDER_ID" ]; then
|
||||
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" "" "$SELECTED_API_BASE" > /dev/null
|
||||
elif [ "$SUBSCRIPTION_MODE" = "kimi_code" ]; then
|
||||
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" "" "$SELECTED_API_BASE" > /dev/null
|
||||
elif [ "$SUBSCRIPTION_MODE" = "hive_llm" ]; then
|
||||
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" "" "$SELECTED_API_BASE" > /dev/null
|
||||
else
|
||||
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" > /dev/null
|
||||
fi
|
||||
@@ -1334,7 +1368,7 @@ fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# Step 4b: Browser Automation (GCU) — always enabled
|
||||
# Browser Automation (GCU) — always enabled
|
||||
# ============================================================
|
||||
|
||||
echo -e "${GREEN}⬢${NC} Browser automation enabled"
|
||||
@@ -1362,10 +1396,10 @@ fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# Step 5: Initialize Credential Store
|
||||
# Step 4: Initialize Credential Store
|
||||
# ============================================================
|
||||
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Step 5: Initializing credential store...${NC}"
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Step 4: Initializing credential store...${NC}"
|
||||
echo ""
|
||||
echo -e "${DIM}The credential store encrypts API keys and secrets for your agents.${NC}"
|
||||
echo ""
|
||||
@@ -1432,10 +1466,10 @@ fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# Step 6: Verify Setup
|
||||
# Step 5: Verify Setup
|
||||
# ============================================================
|
||||
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Step 6: Verifying installation...${NC}"
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Step 5: Verifying installation...${NC}"
|
||||
echo ""
|
||||
|
||||
ERRORS=0
|
||||
@@ -1496,10 +1530,10 @@ if [ $ERRORS -gt 0 ]; then
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Step 7: Install hive CLI globally
|
||||
# Step 6: Install hive CLI globally
|
||||
# ============================================================
|
||||
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Step 7: Installing hive CLI...${NC}"
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Step 6: Installing hive CLI...${NC}"
|
||||
echo ""
|
||||
|
||||
# Ensure ~/.local/bin exists and is in PATH
|
||||
|
||||
@@ -16,6 +16,8 @@ import sys
|
||||
|
||||
import httpx
|
||||
|
||||
from framework.config import HIVE_LLM_ENDPOINT
|
||||
|
||||
TIMEOUT = 10.0
|
||||
|
||||
|
||||
@@ -135,6 +137,10 @@ PROVIDERS = {
|
||||
"kimi": lambda key, **kw: check_anthropic_compatible(
|
||||
key, "https://api.kimi.com/coding/v1/messages", "Kimi"
|
||||
),
|
||||
# Hive LLM uses an Anthropic-compatible endpoint
|
||||
"hive": lambda key, **kw: check_anthropic_compatible(
|
||||
key, f"{HIVE_LLM_ENDPOINT}/v1/messages", "Hive"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +168,10 @@ def main() -> None:
|
||||
result = check_anthropic_compatible(
|
||||
api_key, api_base.rstrip("/") + "/v1/messages", "Kimi"
|
||||
)
|
||||
elif api_base and provider_id == "hive":
|
||||
result = check_anthropic_compatible(
|
||||
api_key, api_base.rstrip("/") + "/v1/messages", "Hive"
|
||||
)
|
||||
elif api_base:
|
||||
# Custom API base (ZAI or other OpenAI-compatible)
|
||||
endpoint = api_base.rstrip("/") + "/models"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
function Get-WorkingUvInfo {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Find a runnable uv executable, not just a PATH entry named "uv"
|
||||
.OUTPUTS
|
||||
Hashtable with Path and Version, or $null if no working uv is found
|
||||
#>
|
||||
# pyenv-win can expose a uv shim that exists on PATH but fails at runtime.
|
||||
# Verify each candidate with `uv --version` before trusting it.
|
||||
$candidates = @()
|
||||
|
||||
$commands = @(Get-Command uv -All -ErrorAction SilentlyContinue)
|
||||
foreach ($cmd in $commands) {
|
||||
if ($cmd.Source) {
|
||||
$candidates += $cmd.Source
|
||||
} elseif ($cmd.Definition) {
|
||||
$candidates += $cmd.Definition
|
||||
} elseif ($cmd.Name) {
|
||||
$candidates += $cmd.Name
|
||||
}
|
||||
}
|
||||
|
||||
$defaultUvExe = Join-Path $env:USERPROFILE ".local\bin\uv.exe"
|
||||
if (Test-Path $defaultUvExe) {
|
||||
$candidates += $defaultUvExe
|
||||
}
|
||||
|
||||
foreach ($candidate in ($candidates | Where-Object { $_ } | Select-Object -Unique)) {
|
||||
try {
|
||||
$versionOutput = & $candidate --version 2>$null
|
||||
$version = ($versionOutput | Out-String).Trim()
|
||||
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($version)) {
|
||||
return @{
|
||||
Path = $candidate
|
||||
Version = $version
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
@@ -25,6 +25,12 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TOOLS_SRC = Path(__file__).resolve().parent / "src"
|
||||
if _TOOLS_SRC.is_dir():
|
||||
tools_src = str(_TOOLS_SRC)
|
||||
if tools_src not in sys.path:
|
||||
sys.path.insert(0, tools_src)
|
||||
|
||||
|
||||
def setup_logger():
|
||||
if not logger.handlers:
|
||||
@@ -52,6 +58,12 @@ if "--stdio" in sys.argv:
|
||||
|
||||
from fastmcp import FastMCP # noqa: E402
|
||||
|
||||
# Import command sanitizer — shared module in aden_tools
|
||||
from aden_tools.tools.file_system_toolkits.command_sanitizer import ( # noqa: E402
|
||||
CommandBlockedError,
|
||||
validate_command,
|
||||
)
|
||||
|
||||
mcp = FastMCP("coder-tools")
|
||||
|
||||
PROJECT_ROOT: str = ""
|
||||
@@ -208,6 +220,8 @@ def run_command(command: str, cwd: str = "", timeout: int = 120) -> str:
|
||||
|
||||
PYTHONPATH is automatically set to include core/ and exports/.
|
||||
Output is truncated at 30K chars with a notice.
|
||||
Commands still execute with shell=True, so the sanitizer blocks
|
||||
explicit nested shell executables but cannot remove shell parsing.
|
||||
|
||||
Args:
|
||||
command: Shell command to execute
|
||||
@@ -222,6 +236,11 @@ def run_command(command: str, cwd: str = "", timeout: int = 120) -> str:
|
||||
|
||||
try:
|
||||
command = _translate_command_for_windows(command)
|
||||
# Validate command against safety blocklist before execution
|
||||
try:
|
||||
validate_command(command)
|
||||
except CommandBlockedError as e:
|
||||
return f"Error: {e}"
|
||||
start = time.monotonic()
|
||||
result = subprocess.run(
|
||||
command,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
HuggingFace credentials.
|
||||
|
||||
Contains credentials for HuggingFace Hub API access.
|
||||
Contains credentials for HuggingFace Hub API and Inference API access.
|
||||
"""
|
||||
|
||||
from .base import CredentialSpec
|
||||
@@ -16,11 +16,16 @@ HUGGINGFACE_CREDENTIALS = {
|
||||
"huggingface_get_dataset",
|
||||
"huggingface_search_spaces",
|
||||
"huggingface_whoami",
|
||||
"huggingface_run_inference",
|
||||
"huggingface_run_embedding",
|
||||
"huggingface_list_inference_endpoints",
|
||||
],
|
||||
required=True,
|
||||
startup_required=False,
|
||||
help_url="https://huggingface.co/settings/tokens",
|
||||
description="HuggingFace API token for Hub access (models, datasets, spaces)",
|
||||
description=(
|
||||
"HuggingFace API token for Hub access (models, datasets, spaces) and Inference API"
|
||||
),
|
||||
direct_api_key_supported=True,
|
||||
api_key_instructions="""To get a HuggingFace token:
|
||||
1. Go to https://huggingface.co/settings/tokens
|
||||
|
||||
@@ -14,10 +14,15 @@ NOTION_CREDENTIALS = {
|
||||
"notion_search",
|
||||
"notion_get_page",
|
||||
"notion_create_page",
|
||||
"notion_update_page",
|
||||
"notion_query_database",
|
||||
"notion_get_database",
|
||||
"notion_update_page",
|
||||
"notion_archive_page",
|
||||
"notion_create_database",
|
||||
"notion_update_database",
|
||||
"notion_get_block_children",
|
||||
"notion_get_block",
|
||||
"notion_update_block",
|
||||
"notion_delete_block",
|
||||
"notion_append_blocks",
|
||||
],
|
||||
required=True,
|
||||
|
||||
@@ -67,7 +67,7 @@ SLACK_CREDENTIALS = {
|
||||
help_url="https://api.slack.com/apps",
|
||||
description="Slack Bot Token (starts with xoxb-)",
|
||||
# Auth method support
|
||||
aden_supported=True,
|
||||
aden_supported=False,
|
||||
aden_provider_name="slack",
|
||||
direct_api_key_supported=True,
|
||||
api_key_instructions="""To get a Slack Bot Token:
|
||||
|
||||
@@ -223,6 +223,7 @@ def _register_verified(
|
||||
register_telegram(mcp, credentials=credentials)
|
||||
register_google_docs(mcp, credentials=credentials)
|
||||
register_google_maps(mcp, credentials=credentials)
|
||||
register_notion(mcp, credentials=credentials)
|
||||
register_account_info(mcp, credentials=credentials)
|
||||
|
||||
|
||||
@@ -272,7 +273,6 @@ def _register_unverified(
|
||||
register_microsoft_graph(mcp, credentials=credentials)
|
||||
register_mongodb(mcp, credentials=credentials)
|
||||
register_n8n(mcp, credentials=credentials)
|
||||
register_notion(mcp, credentials=credentials)
|
||||
register_obsidian(mcp, credentials=credentials)
|
||||
register_pagerduty(mcp, credentials=credentials)
|
||||
register_pinecone(mcp, credentials=credentials)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user