Compare commits

...

551 Commits

Author SHA1 Message Date
Richard Tang c67521a09c chore: ruff lint 2026-04-20 19:14:14 -07:00
Richard Tang 8da06f4f90 Merge remote-tracking branch 'origin/feat/queue-message' into feat/colony-merge-candidate
# Conflicts:
#	core/frontend/src/components/ChatPanel.tsx
#	core/frontend/src/pages/colony-chat.tsx
#	core/frontend/src/pages/queen-dm.tsx
2026-04-20 19:11:58 -07:00
Richard Tang 46e0413eb8 chore: create colony popup 2026-04-20 19:01:43 -07:00
Richard Tang 81731587ff feat(tool call): add format _coerce before execution 2026-04-20 18:58:12 -07:00
Richard Tang 4e9d9bf1ea feat: group tools by sessions 2026-04-20 18:20:10 -07:00
Richard Tang 2644ab953d fix: tool calls in chat 2026-04-20 18:10:53 -07:00
Richard Tang e7daa59573 feat: queen ask_user tool prompt 2026-04-20 16:48:43 -07:00
Richard Tang 1bec43afad feat: ask_user tool prompt 2026-04-20 16:38:29 -07:00
Richard Tang 3d1357595d refactor: ask_user 2026-04-20 16:34:18 -07:00
bryan 59ccbba810 fix: suppress typing flicker on queue auto-flush and dedup user bubble on bootstrap race 2026-04-20 15:30:01 -07:00
Richard Tang 8b2ae369ac fix:remove deuplicate parts in indenpendent prompt 2026-04-20 14:52:32 -07:00
Richard Tang 96a667cbd9 feat: better identity prompt structure 2026-04-20 14:41:20 -07:00
Richard Tang 17150a53bd chore: lint 2026-04-20 13:09:02 -07:00
Richard Tang c1d7b0ee69 feat: fix reply message bubble and improve code reuse 2026-04-20 13:07:26 -07:00
bryan 16ea9b52d3 feat: queue messages during queen turns in colony/queen chats 2026-04-20 12:45:38 -07:00
bryan dcbfd4ab01 feat: add pending-queue hook and Steer/Cancel UI in ChatPanel 2026-04-20 12:45:14 -07:00
bryan b762020793 refactor: carry executionId on user SSE events 2026-04-20 12:44:56 -07:00
Richard Tang 4ffddc53e6 fix: trigger message 2026-04-20 11:54:11 -07:00
Richard Tang 24bcc5aea7 feat: update trigger ui 2026-04-20 11:19:57 -07:00
Richard Tang 3c91119f67 feat: improvements for scheduler 2026-04-20 10:49:37 -07:00
Richard Tang 923e773c14 feat: improve the tab switching tool 2026-04-20 10:21:32 -07:00
Naresh Chandanbatve 199c3a235e feat(tool): add Prometheus tool support (#7047)
Adds prometheus_query (instant PromQL) and prometheus_query_range
(time-range) tools. Includes credential spec, /-/ready health check,
unit tests, and docs.

Optional Bearer token and Basic auth via env vars
(PROMETHEUS_TOKEN, PROMETHEUS_USERNAME/PASSWORD).

Fixes #6945.
2026-04-20 18:13:49 +08:00
Kavin a881fe68da fix(llm): ensure store=False is passed to Codex Responses API (#7089)
Forces store: false into the extra_body payload for Codex-style models
so that LiteLLM successfully passes it down to the ChatGPT Responses
API backend, fixing the BadRequestError.

Fixes #7056.

Original investigation and first PR by @Darshan174 (#7065).

Co-authored-by: Darshan174 <Darshan002321@gmail.com>
2026-04-20 17:54:41 +08:00
Hundao 6b9040477f fix(ci): unbreak main, ruff format browser and refresh test_model_catalog (#7095)
* chore: ruff format browser bridge and tools

* fix(tests): refresh test_model_catalog expectations after catalog drift
2026-04-20 17:23:26 +08:00
Richard Tang c7cc031060 fix: handling broken Aden API Key 2026-04-19 20:05:14 -07:00
Richard Tang 93c0ef672a fix: queen badge 2026-04-19 19:37:49 -07:00
Richard Tang 67d55e6cce feat: scheduler tools for incubating 2026-04-19 19:30:31 -07:00
Richard Tang 0907ff9cec Merge branch 'pr-7093-vincent' into feat/colony-session-transfer 2026-04-19 19:01:19 -07:00
Vincent Jiang ed2e7125ac feat: colony creation, queen identity in colonies, and org chart improvements
- Colony creation: add "Create a Colony" button in queen DM (conversation header),
  queen profile panel, and sidebar with queen picker + goal input
- Queen identity in colonies: resolve queen profile name for colony chat messages,
  fix duplicate messages on refresh via SSE replay deduplication with restore cutoff
- Colony header: show colony name with Component icon, queen profile link preserved
- Org chart: colony detail drawer with metadata (start date, goal, status, stats),
  icon picker for colonies (16 icons, persisted to metadata.json), fixed queen card
  heights, fixed queen display order via shared sortQueenProfiles()
- Chat: add headerAction slot for inline buttons next to "Conversation" header
- Backend: PATCH /api/agents/metadata for colony icon, created_at in discover API
  with filesystem fallback, chat-helpers queen name passthrough for cold restore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 18:55:01 -07:00
Richard Tang f39c1c87af feat: compact the queen session when creating colony 2026-04-19 18:51:51 -07:00
Richard Tang 1229b4ad4d feat: incubating phase 2026-04-19 18:07:09 -07:00
Richard Tang 0d11a946a5 feat: mark-colony-spawned for a session that created colony 2026-04-19 17:21:06 -07:00
Richard Tang b007ed753b chore: xiaomi model context limit 2026-04-19 15:28:32 -07:00
Richard Tang bb39424e99 chore: update model context config 2026-04-19 15:19:26 -07:00
Richard Tang b27c7a029e chore: update openrouter model selections 2026-04-19 15:10:36 -07:00
Timothy a3433f2c9e Merge branch 'main' into fix/image-coordinate-precision 2026-04-19 13:25:41 -07:00
Richard Tang 24ef2c247d chore: tidy editorconfig and gitattributes, drop unused reference 2026-04-19 13:24:34 -07:00
Richard Tang a8f9661626 chore: remove unused files 2026-04-19 13:19:01 -07:00
Timothy 3005bcaa96 chore: bump extension version to 1.0.1 2026-04-19 13:06:51 -07:00
Timothy 40c4591d65 fix: extension icons 2026-04-19 13:06:13 -07:00
Timothy e2bfb9d3af fix: frame resize 2026-04-19 13:02:12 -07:00
Timothy e55cea97ef fix: diagnostics 2026-04-19 12:52:04 -07:00
Timothy ddaafe0307 Merge remote-tracking branch 'origin/main' into fix/image-coordinate-precision 2026-04-18 23:32:28 -07:00
Richard Tang c17205a453 test: align stale tests with current behavior 2026-04-18 22:02:03 -07:00
Richard Tang 8e4468851c chore: ruff format 2026-04-18 21:45:34 -07:00
Richard Tang ccf4216841 fix: resolve merge conflict markers and ruff issues 2026-04-18 21:45:11 -07:00
Richard Tang 82ffcb17ac Merge remote-tracking branch 'origin/main' into fix/colony-skill-leak 2026-04-18 21:36:23 -07:00
Richard Tang 4da5bcc1e4 feat: queen bar in colony 2026-04-18 21:30:19 -07:00
Richard Tang 3df7194003 feat: worker tab by clicking on the worker 2026-04-18 21:21:22 -07:00
Richard Tang 6f1f27b6e9 feat: load table by colony 2026-04-18 20:55:20 -07:00
Richard Tang 7b52ed9fa7 fix: outdated jsonledger 2026-04-18 20:35:05 -07:00
Richard Tang 4d32526a29 feat: real available parallel size 2026-04-18 20:18:54 -07:00
Richard Tang 656401e199 feat: real snapshot after interaction 2026-04-18 19:51:52 -07:00
Richard Tang f2e51157dc feat: snapshot related prompts 2026-04-18 19:39:00 -07:00
Timothy 0d13c805b1 fix: colony skill leakage 2026-04-18 15:34:31 -07:00
Kowshik Mente b1ec64438c fix(runtime): prevent session restart until cancelled execution fully terminates (#7001)
* fix(runtime): prevent dual execution after forced cancel

- keep bookkeeping until task termination
- block restart while any execution task is still alive
- make execution registration atomic under lock
- avoid premature cleanup on cancel timeout
- add regression tests for forced-cancel restart scenarios

* chore: ruff format and import order

---------

Co-authored-by: kowshikmente <kowshikmente@kowshikmentes-MacBook-Pro.local>
Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-04-18 19:36:50 +08:00
Hundao 90aadf247a fix(ci): unbreak main — ruff format, test_refs, test_model_catalog (#7084)
* fix(ci): apply ruff format to browser tool files

Refs #7083

* fix(ci): unbreak test_refs (img regression) and test_model_catalog

test_refs:
- Add `img` back to CONTENT_ROLES so named images get refs again. The
  recent `cc6ec97a feat: multiple modes browser snapshot tool` refactor
  renamed NAMED_CONTENT_ROLES → CONTENT_ROLES and accidentally dropped
  `img`, breaking `test_named_content_roles_get_refs`.
- Drop the `navigation` assertion from `test_skips_structural_roles`.
  That same refactor intentionally added landmark roles (navigation,
  main, listitem) to CONTENT_ROLES so AI agents can ref them by name,
  and the test was not updated to reflect that.

test_model_catalog:
- Add 5 openrouter models that were added to model_catalog.json by
  #7081 (UI/UX improvements) but not reflected in the test.

Refs #7083

* fix(ci): wait for event propagation in subagent report test on Windows

`test_worker_report_emits_subagent_report_event` waited only for
`worker.is_active` to flip to False, then immediately asserted on the
collected events. On Windows the event loop scheduling differs enough
that the SUBAGENT_REPORT subscriber callback can run a few ticks after
the worker is marked inactive, so the assertion fires against an empty
list. Wait for both conditions.

Refs #7083
2026-04-18 19:09:15 +08:00
RichardTang-Aden 49317ac5f5 Merge pull request #7081 from vincentjiang777/feat/ui-ux-improvements
feat: UI/UX improvements across BYOK, org chart, profiles, and prompt…
2026-04-17 21:03:01 -07:00
Richard Tang 7216e9d9f0 chore: ruff lint and format
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:01:18 -07:00
Richard Tang 91b1070d80 Merge remote-tracking branch 'origin/main' into feat/ui-ux-improvements
# Conflicts:
#	core/frontend/src/components/SidebarQueenItem.tsx
2026-04-17 20:58:20 -07:00
Richard Tang 08aeffd977 chore: more create colony logs 2026-04-17 20:27:22 -07:00
Richard Tang 651b57b928 feat: hive open performance issue 2026-04-17 20:16:01 -07:00
Richard Tang 8c10fc2e1c fix: queen dm session loading 2026-04-17 20:11:48 -07:00
Richard Tang e3154ca0ee fix: colony session loading 2026-04-17 19:45:31 -07:00
Richard Tang 84a92af41b fix: patch the correct db path 2026-04-17 19:40:59 -07:00
Richard Tang 78fc62210a feat table tab improvements 2026-04-17 19:25:15 -07:00
Timothy 2fd7e9172a fix: y-offset inspection 2026-04-17 19:24:41 -07:00
Richard Tang ca63fd9ee9 feat: create skill along with colony 2026-04-17 19:03:28 -07:00
Richard Tang b99f25c8d7 feat: DataGrid for colony side bar 2026-04-17 18:47:19 -07:00
Timothy e972112074 feature: merge sidebars with functionalities 2026-04-17 18:12:18 -07:00
Vincent Jiang 6e97191f21 feat: UI/UX improvements across BYOK, org chart, profiles, and prompt library
- BYOK: unified styling (remove purple, consistent grey headers), model selector opens settings modal directly, backend validates API keys before activation
- Org chart: queen profiles are now editable (name, title, about, skills, achievement) with changes persisted to YAML
- Avatars: upload profile pictures for queens and user with client-side compression, displayed across org chart, sidebar, chat, and header
- Colony deletion: await backend delete and re-fetch to prevent ghost colonies
- Prompt library: add pagination (24/page), custom prompt upload/delete with backend persistence
- Settings modal: performance cleanup (remove backdrop-blur, reduce transitions)
- Fix ensure_default_queens() overwriting user edits on every API call

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:21:18 -07:00
Richard Tang 023fb9b8d0 refactor: use SSE for worker and browser status 2026-04-17 14:11:19 -07:00
Richard Tang b7924b1ad0 feat: colony tab by group 2026-04-17 14:05:55 -07:00
Timothy b6640b8592 fix: prevent watcher to be gced 2026-04-17 13:13:39 -07:00
Timothy 43a1d5797c Merge branch 'fix/worker-tab-groups' into feature/clean-context 2026-04-17 12:35:09 -07:00
Timothy 5cb814f2dc fix: worker tab groups 2026-04-17 12:34:38 -07:00
Richard Tang f52c44821a feat: partially validation after typing 2026-04-17 12:16:13 -07:00
Richard Tang 97432ea08c feat: colony side bar 2026-04-17 11:52:49 -07:00
Timothy 0abd1125b7 fix: parallel execution 2026-04-17 11:20:06 -07:00
Timothy 803337ec74 feat: new queen phases 2026-04-17 06:19:15 -07:00
Timothy 2b055d4d42 fix: simplify system prompt 2026-04-17 04:47:51 -07:00
Timothy dde4dfaec9 Merge branch 'feature/colony-sqlite' into feature/clean-context 2026-04-17 04:12:35 -07:00
Timothy 6be026fcb1 fix: partial parts and system nudge 2026-04-17 04:06:59 -07:00
Richard Tang 3c2161aad5 chore: release v0.10.2
Release / Create Release (push) Waiting to run
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:43:20 -07:00
Richard Tang e74ebe6835 feat: reduce gemini context window to improve reliability 2026-04-16 23:41:24 -07:00
Richard Tang d788e5b2f7 chore: ruff lint 2026-04-16 23:33:48 -07:00
Richard Tang 583a5b41b4 fix: ununsed reference 2026-04-16 23:23:38 -07:00
Richard Tang 83cc44bdef Merge branch 'feature/full-image-size' 2026-04-16 23:15:59 -07:00
Timothy 558813e7fa feat: fraction-based visual clicks 2026-04-16 22:36:41 -07:00
Timothy aba0ff07ba fix: model invariant screenshot 2026-04-16 20:29:05 -07:00
Timothy 4303a36df0 fix: namespaced browser tab groups 2026-04-16 20:07:05 -07:00
Timothy e68d8ef10b fix: do not kill queen when switching 2026-04-16 19:29:00 -07:00
Richard Tang c6b6a5a2f7 feat: GCP skills and prompts improvements 2026-04-16 17:43:52 -07:00
Richard Tang 18f5f078fc feat: dashed highlighter for browser type focus 2026-04-16 17:26:09 -07:00
Richard Tang cc6ec97a75 feat: multiple modes browser snapshot tool 2026-04-16 17:22:44 -07:00
Richard Tang 44d114f0d0 feat: default 1ms delay and prompt improvements 2026-04-16 16:19:38 -07:00
Richard Tang 9e71f16d15 Merge remote-tracking branch 'origin/fix/browser-behaviour-improvements' into fix/browser-behaviour-improvements 2026-04-16 16:14:43 -07:00
Richard Tang 28cad2376c feat: separate type focus tool 2026-04-16 16:08:43 -07:00
Timothy 8222cd306e fix: simplify canonical workflow 2026-04-16 16:02:37 -07:00
Timothy b50f237506 fix: screenshot skill diction 2026-04-16 15:16:22 -07:00
Richard Tang 916803889f feat: browswer control tools improvement and debugger 2026-04-16 15:14:08 -07:00
Timothy 59b1bc9338 fix: tool grouping logic 2026-04-16 12:55:10 -07:00
Timothy 37672c5581 fix: remove worker tool from dm 2026-04-16 12:23:19 -07:00
Timothy 7b0948cd62 Merge branch 'refactor/worker-message' into feature/colony-sqlite 2026-04-16 11:26:46 -07:00
Timothy 4aa5fd7a90 refactor: align worker display 2026-04-16 11:26:32 -07:00
Richard Tang d20b617008 feat: queen profile in message bubbles 2026-04-16 11:21:02 -07:00
Timothy c4ee12532f fix: worker message display 2026-04-16 11:20:17 -07:00
Richard Tang 36ebf27e3e feat: make side bar size adjustble 2026-04-16 11:15:47 -07:00
Richard Tang ae1599c66a feat: queen profile side bar 2026-04-16 11:15:30 -07:00
Richard Tang 810cf5a6d3 Merge remote-tracking branch 'origin/main' into feature/colony-sqlite 2026-04-16 11:10:34 -07:00
Timothy 1ee0d5a2e8 feat: worker bubble display 2026-04-16 10:48:44 -07:00
Hundao 9051c443fb fix(tests): resolve Windows CI failures (#7061)
- test_background_job: use sys.executable and double quotes instead of
  single-quoted 'python -c' which Windows cmd.exe doesn't understand
- test_cli_entry_point: guard against None stdout on Windows with
  (result.stdout or "").lower()
- test_safe_eval: bump DEFAULT_TIMEOUT_MS from 100 to 500 to accommodate
  slow Windows CI runners where SIGALRM is unavailable
2026-04-16 21:05:09 +08:00
Hundao e5a93b059f fix(tests): resolve test failures across framework and tools (#7059)
* fix(tests): resolve test failures across framework and tools

Framework tests (52 -> 1 failure):
- Add missing `model` attribute to mock LLM classes (MockStreamingLLM,
  CrashingLLM, ErrorThenSuccessLLM, etc.) to match new agent_loop.py
  requirement at line 624
- Update skill count assertions from 6 to 7 (new writing-hive-skills)
- Fix phase compaction test to match new message format (no brackets)
- Update model catalog test for current gemini model names
- Fix queen memory test: set phase="building" to match prompt_building,
  adjust reflection trigger count to match cooldown behavior

Tools tests (52 -> 0 failures):
- Update csv_tool tests: remove agent_id parameter, use absolute paths,
  patch _ALLOWED_ROOTS instead of AGENT_SANDBOXES_DIR
- Fix browser_evaluate test to allow toast wrapper around script

Remaining: 1 pre-existing failure in test_worker_report where mock LLM
gets stuck when scenarios are exhausted (separate bug).

* fix(tests): resolve remaining test failures

- Add text stop scenario to test_worker_report so worker terminates
  cleanly after tool_calls finish instead of replaying the last
  scenario forever
- Remove duplicated hive home isolation fixture from test_colony_fork_live;
  reuse conftest autouse fixture and only add config copy on top

* fix(tests): prevent mock LLM infinite loops on exhausted scenarios

fix(core): accept both pruned tool result sentinel formats

MockStreamingLLM and _ByTaskMockLLM replay the last scenario forever
when call_index exceeds the scenario list, causing worker timeouts in
CI. Fix by emitting a text stop when scenarios are exhausted (scenarios
mode) or already consumed (by_task mode).

Also fix pruned tool result sentinel mismatch: conversation.py produces
"Pruned tool result ..." but compaction.py and conversation.py only
checked for "[Pruned tool result". Now both formats are accepted.

Also remove duplicated hive home isolation fixture from
test_colony_fork_live; reuse conftest autouse fixture instead.
2026-04-16 20:13:43 +08:00
Hundao 589c5b06fe fix: resolve all ruff lint and format errors across codebase (#7058)
- Auto-fixed 70 lint errors (import sorting, aliased errors, datetime.UTC)
- Fixed 85 remaining errors manually:
  - E501: wrapped long lines in queen_profiles, catalog, routes_credentials
  - F821: added missing TYPE_CHECKING imports for AgentHost, ToolRegistry,
    HookContext, HookResult; added runtime imports where needed
  - F811: removed duplicate method definitions in queen_lifecycle_tools
  - F841/B007: removed unused variables in discovery.py
  - W291: removed trailing whitespace in queen nodes
  - E402: moved import to top of queen_memory_v2.py
  - Fixed AgentRuntime -> AgentHost in example template type annotations
- Reformatted 343 files with ruff format
2026-04-16 19:30:01 +08:00
Richard Tang be94c611bd fix: queen fail when no worker is running 2026-04-15 22:14:36 -07:00
Timothy 45df68c146 feat: ensure sqlite3 installation 2026-04-15 18:34:33 -07:00
Richard Tang 4fdbc438f9 chore: release v0.10.1
Release / Create Release (push) Waiting to run
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:15:40 -07:00
Timothy 2231dc5742 fix: delete spilled skill 2026-04-15 18:14:10 -07:00
Timothy 446844b2ad fix: tighten worker with sqlite skills 2026-04-15 18:11:15 -07:00
Richard Tang 78301274cd feat: broswer tool improvements 2026-04-15 18:09:28 -07:00
Timothy e719523434 fix: remove conflicting tools 2026-04-15 17:38:05 -07:00
Richard Tang 451a5d55d2 feat: queen independent prompt improvements 2026-04-15 17:36:48 -07:00
Richard Tang e2a21b3613 chore: title of finance 2026-04-15 16:55:00 -07:00
Richard Tang 5c251645d3 Merge branch 'main' into feat/gui-ux-updates 2026-04-15 16:45:39 -07:00
Richard Tang 8783f372fc feat: use the customtools model for gemini 2026-04-15 16:44:23 -07:00
bryan 2790d13bb6 Merge branch 'main' into feat/gui-ux-updates 2026-04-15 15:45:56 -07:00
bryan 900d94e49f feat: add message timestamps, day-divider rows, and stable createdAt across stream updates 2026-04-15 15:45:31 -07:00
bryan 70e3eb539b feat: extract QueenProfilePanel and open it from the app header 2026-04-15 15:45:20 -07:00
bryan deeb7de800 feat: sort queens by last DM activity and trim "Head of" title prefix 2026-04-15 15:44:52 -07:00
bryan 57ad98005d feat: derive last_active_at from latest message timestamp and sort history newest-first 2026-04-15 15:44:32 -07:00
Timothy 79c5d43006 feat: colony sqlite and skills 2026-04-15 15:28:37 -07:00
Timothy 252710fb41 fix: context health and eviction 2026-04-15 11:40:45 -07:00
Richard Tang 22df99ef51 Merge remote-tracking branch 'origin/main'
Release / Create Release (push) Waiting to run
2026-04-14 19:56:33 -07:00
Richard Tang edc3135797 Merge branch 'feature/new-colony' 2026-04-14 19:56:08 -07:00
Richard Tang 27b15789fb fix: skills prompts 2026-04-14 18:51:14 -07:00
RichardTang-Aden 5ba5933edc Merge pull request #7046 from vincentjiang777/main
docs: new readme
2026-04-14 18:02:49 -07:00
Timothy 50eb4b0e8f Merge branch 'feature/colony-creation' into feature/new-colony 2026-04-14 16:34:30 -07:00
Richard Tang 3e4a4c9924 Merge remote-tracking branch 'origin/feat/text-only-tool-filter' into feature/new-colony 2026-04-14 16:29:19 -07:00
Richard Tang c47987e73c fix: ask user widget fallback 2026-04-14 16:27:12 -07:00
Timothy 256b52b818 fix: skills for colonies 2026-04-14 16:23:17 -07:00
Richard Tang 8f5daf0569 fix: swtiching model and new chat 2026-04-14 16:04:07 -07:00
bryan af5c72e785 feat: hide image-producing tools and vision-only prompt blocks from text-only models 2026-04-14 12:50:44 -07:00
Timothy 958bafea29 fix: tool gated skill activation 2026-04-14 11:17:03 -07:00
bryan 5cdc01cb8c fix: preserve tool pill mapping across turn boundary for deferred ask_user completions 2026-04-14 10:56:38 -07:00
Timothy 6979ea825d fix: remove tool limit 2026-04-14 10:35:08 -07:00
Timothy d6093a560f Merge branch 'feature/new-colony' into feature/colony-creation 2026-04-14 10:19:24 -07:00
Hundao 2f58cce781 fix(tools): web_scrape truncation no longer exceeds max_length (#7044)
The previous code did `text[:max_length] + "..."`, which made the
returned content always 3 chars longer than the requested max_length.
Reserve room for the ellipsis inside the limit so the contract holds.

Fixes #2098
2026-04-14 14:24:42 +08:00
Richard Tang ab76a66646 fix: queen loading 2026-04-13 22:39:39 -07:00
Richard Tang c575ff3fe7 feat: queen messages improvements 2026-04-13 22:31:49 -07:00
Timothy 8668d103a8 Merge branch 'feature/new-colony' into feature/colony-creation 2026-04-13 21:34:17 -07:00
Timothy 133f393f8b feat: scheduled triggers 2026-04-13 21:33:54 -07:00
Timothy fd3ef36a15 fix: side panel 2026-04-13 21:08:11 -07:00
Timothy aa281aad34 fix: remove deprecated graphs 2026-04-13 20:56:47 -07:00
Richard Tang a3d0c7e0cb fix: remove No ask_user prompt in the examples 2026-04-13 20:54:17 -07:00
Richard Tang de3042ba3f fix Prompt in the home page are not given to the queen directly, users have to wait till the hello message to be finished. 2026-04-13 20:34:11 -07:00
Timothy 326d7f201c Merge branch 'feature/new-colony' into feature/colony-creation 2026-04-13 19:59:34 -07:00
Timothy db30ef3094 fix: reframe colony creation 2026-04-13 19:56:14 -07:00
Timothy e3d1cb6739 fix: colony creation link 2026-04-13 19:46:24 -07:00
Timothy 846f3f2470 feat: improve tool call reliability 2026-04-13 19:34:47 -07:00
Richard Tang 913437ea0b fix: build error 2026-04-13 18:06:40 -07:00
Richard Tang 520bd635e2 Merge branch 'feature/hive-experimental-comp-pipeline' into feature/new-colony 2026-04-13 18:02:34 -07:00
bryan b7d850ddd0 feat: add LLM key validation endpoint, emit agent errors via SSE, and improve key management UI 2026-04-13 16:25:43 -07:00
Timothy 0a251278f1 feat: learned default skills 2026-04-13 10:34:25 -07:00
Timothy 857af8e6a3 fix: gcu system prompt 2026-04-13 10:00:00 -07:00
Timothy 273d4ec66e fix: upgrade browser skills 2026-04-13 09:45:07 -07:00
Timothy eeb46a2b3e fix: tool credential filter 2026-04-11 12:54:26 -07:00
Timothy b5e05fefae fix: screenshot 2026-04-11 09:53:53 -07:00
Timothy bdfbb7698a fix: browser click 2026-04-10 23:34:39 -07:00
Timothy 35b1eadb7f fix: improve reliability 2026-04-10 22:46:30 -07:00
Timothy 38036eb7bd fix: reliability tunes 2026-04-10 22:12:13 -07:00
Timothy 70d90fda19 fix: screenshot 2026-04-10 21:11:49 -07:00
vincentjiang777 9dc214cfd2 Merge branch 'aden-hive:main' into main 2026-04-10 20:35:42 -07:00
Bryan 1e3dcbbbc2 feat: ask user tool in queen prompt 2026-04-10 17:46:18 -07:00
Bryan 53b095cdcb feat: use ask_user and ask_user_multiple 2026-04-10 17:31:32 -07:00
Timothy d04862053f fix: queen instruction on colony creation 2026-04-10 17:31:01 -07:00
Timothy df0e0ea082 Merge branch 'fix/after-colony-refresh' into feature/new-colony 2026-04-10 17:19:22 -07:00
Timothy b1724ee360 fix: after colony creation list needs refresh 2026-04-10 17:18:59 -07:00
Bryan a59493835d fix: new session for prompt library and new chat 2026-04-10 17:17:55 -07:00
Timothy 334af2b74e fix: default log level 2026-04-10 16:58:27 -07:00
Richard Tang 81c72949ce feat: prompt library ui improvement 2026-04-10 16:54:34 -07:00
Timothy 97fd45d36a fix: mcp tool initialization 2026-04-10 16:52:04 -07:00
Timothy caebbea1aa fix: initialize default mcps 2026-04-10 16:42:03 -07:00
Richard Tang 574a3a284e Merge remote-tracking branch 'origin/feature/new-colony' into feature/new-colony 2026-04-10 16:38:50 -07:00
Richard Tang 8ea3fb8cfe chore: align the hive tool names 2026-04-10 16:38:21 -07:00
Timothy 69d16a8f6c fix: remove deprecated tools 2026-04-10 16:26:29 -07:00
Richard Tang f16cb0ea1f fix: frontend dm fix 2026-04-10 16:25:33 -07:00
Richard Tang e0f1e9d494 feat: efficient mcp loading in initialization 2026-04-10 16:23:36 -07:00
Richard Tang 7fb0da26fc feat: register available MCP tools 2026-04-10 16:01:42 -07:00
Timothy f5f72c1c9c Merge branch 'feature/hive-experimental-comp-pipeline' into feature/new-colony 2026-04-10 15:56:41 -07:00
Timothy 06d0a16201 Merge branch 'feature/colony-orchestrate' into feature/new-colony 2026-04-10 15:52:16 -07:00
Timothy 0964758b12 Merge branch 'feature/colony-orchestrate' into feature/hive-experimental-comp-pipeline 2026-04-10 15:48:02 -07:00
Bryan c25abdfd84 feat: natural chat replies + cleaner home-prompt bootstrap 2026-04-10 15:47:28 -07:00
Timothy af720bb569 fix: stop worker 2026-04-10 15:40:35 -07:00
Bryan b763226a64 docs: update references for orchestrator/host/loader renames 2026-04-10 15:39:36 -07:00
Timothy 9b7580d22b fix: colony event bus subscription 2026-04-10 15:33:44 -07:00
Timothy c23c274ac7 feat: colony creation with skill 2026-04-10 15:09:27 -07:00
Timothy 1335a15341 Merge branch 'feature/new-colony' into feature/colony-orchestrate 2026-04-10 12:47:38 -07:00
Timothy 2a1cbaa582 fix: worker spawn 2026-04-10 12:47:14 -07:00
Richard Tang 74cba57cce Merge remote-tracking branch 'origin/feature/new-colony-credentials' into feature/new-colony 2026-04-10 12:15:11 -07:00
Richard Tang 7616de2417 feat: escaltion and queen reply tools 2026-04-10 12:14:49 -07:00
Richard Tang d96875932a fix: correct aden support tag 2026-04-10 12:03:39 -07:00
Richard Tang 238d90871a feat: stable credential states 2026-04-10 11:33:34 -07:00
Timothy e38e1563ba fix: worker execution 2026-04-10 10:26:29 -07:00
Timothy e3d8b89b69 fix: tool blacklist 2026-04-10 09:07:17 -07:00
Timothy ec64c14d37 fix: test cases 2026-04-09 23:51:51 -07:00
Timothy fb5b7ed9de fix: integration tests 2026-04-09 23:05:11 -07:00
Timothy da0aa65c31 refactor: big test cleanup 2026-04-09 22:04:23 -07:00
Timothy cbf7cc0a37 feat(agent): simple fork 2026-04-09 20:42:28 -07:00
Richard Tang 802f64f4a7 feat: cooldown for reflection 2026-04-09 19:00:10 -07:00
Richard Tang 9ad95fde59 chore: ruff lint 2026-04-09 18:22:16 -07:00
Richard Tang b812f6a03a feat: user memory structure and identity 2026-04-09 18:09:38 -07:00
Richard Tang 0299a87d0c fix: queen identity for new session 2026-04-09 18:07:42 -07:00
Timothy 4aa2358211 feat: doppelganger wiring 2026-04-09 18:04:45 -07:00
Richard Tang bc8a97079e feat: queen role and examples 2026-04-09 17:55:22 -07:00
Richard Tang 6eaa609f63 feat: queen scope memory 2026-04-09 17:33:14 -07:00
Bryan 8f0101b273 fix(queen): handle extra text in selector JSON response 2026-04-09 17:13:20 -07:00
Bryan 5ee98ac7cf feat: add prompt library with search and category filtering 2026-04-09 17:00:09 -07:00
Bryan c058029ac0 feat: add aden credentials storage adapter 2026-04-09 16:59:16 -07:00
Bryan 6a79728d99 feat: update model switcher and enhance queen DM page with navigation 2026-04-09 16:58:55 -07:00
Bryan 200c202465 refactor: update provider descriptions and simplify subscription activation 2026-04-09 16:58:36 -07:00
Bryan 791da46f59 feat: add subscription-based LLM config activation endpoint 2026-04-09 16:58:21 -07:00
Bryan 6377c5b094 refactor: cache tool registry and add queen identity selection hook 2026-04-09 16:58:09 -07:00
Bryan 8f4e901c3c feat: add kimi and hive providers to model catalog 2026-04-09 16:57:53 -07:00
Timothy 4be61ebfc7 refactor: shatter the eld*n ring 2026-04-09 16:57:43 -07:00
Richard Tang ac46ce7bfb fix: unavailable minimax model and enhance reflection log 2026-04-09 16:37:09 -07:00
Richard Tang 110d7e0075 fix: remove outdated queen communication prompt 2026-04-09 15:36:56 -07:00
Richard Tang 749185e760 feat: queen dm prompt 2026-04-09 15:26:35 -07:00
Richard Tang 5cb75d1822 chore: instruction on resetting the port 2026-04-09 15:01:22 -07:00
Richard Tang 3febef106d fix: queen identity loading 2026-04-09 14:47:42 -07:00
Richard Tang db18186825 Merge remote-tracking branch 'origin/feature/hive-experimental-comp-pipeline' into feature/hive-experimental-comp-pipeline 2026-04-09 13:59:25 -07:00
Richard Tang 87918b5263 feat: queen selection like a CEO 2026-04-09 13:58:38 -07:00
Bryan @ Aden 01f258c4c4 Merge pull request #7006 from vincentjiang777/main
micro-fix: readme & 500 use cases
2026-04-09 13:46:36 -07:00
Vincent Jiang 3d992bbda3 readme & 500 use cases 2026-04-09 13:43:35 -07:00
Timothy df43f36385 fix: issues 2026-04-09 12:59:42 -07:00
Richard Tang bdd099bb78 feat: queen selection prompt 2026-04-09 12:58:59 -07:00
Richard Tang acca008772 feat: update provider config 2026-04-09 11:59:41 -07:00
Richard Tang 0bf4d8b9fa fix: session resume 2026-04-09 11:44:03 -07:00
Richard Tang 7a2752eb42 feat: consolidate model config 2026-04-09 09:53:05 -07:00
Timothy c65b43c21b Merge branch 'feature/browser-use-fix' into feature/hive-experimental-comp-pipeline 2026-04-09 08:53:37 -07:00
Timothy 90f376136e fix: always on tools 2026-04-09 07:21:24 -07:00
Richard Tang d5ea28f8f3 chore: loading message 2026-04-08 19:11:46 -07:00
Richard Tang 1ccfc7aefa feat: update the model config and selection 2026-04-08 19:09:30 -07:00
Timothy 64830a6720 fix: config validation 2026-04-08 19:03:26 -07:00
Timothy 514d2828fa fix: tool issues 2026-04-08 18:52:34 -07:00
Richard Tang 5705647364 feat: new session for the queen 2026-04-08 18:42:10 -07:00
Richard Tang 8a3e1e68a9 feat: route the new user request into a queen session and add swtich for queen sessions 2026-04-08 18:31:46 -07:00
Richard Tang 4c900e9ab2 fix: position of queen tool bubble 2026-04-08 18:21:13 -07:00
Richard Tang fa0518b249 fix: show tool calls in queen dm message 2026-04-08 17:58:15 -07:00
Richard Tang 6a5bc0d484 fix: edge case causing message injection in session resume 2026-04-08 17:48:59 -07:00
Bryan d288c865d0 feat: sync user profile to global memory as user-profile.md; add queen profile API transformation 2026-04-08 17:42:57 -07:00
bryan 81051a11fc Merge branch 'feature/hive-experimental-comp-pipeline' into feat/open-hive-colony 2026-04-08 16:53:39 -07:00
Richard Tang c4a8c73b24 Merge remote-tracking branch 'origin/feature/hive-experimental-comp-pipeline' into feature/hive-experimental-comp-pipeline 2026-04-08 16:49:17 -07:00
Richard Tang 2b8ed0eb05 fix: bug causing queen message injection when resuming a session 2026-04-08 16:48:46 -07:00
Timothy 40c530603b fix: internal tag choice of diction 2026-04-08 16:38:55 -07:00
Timothy dee3980dbe fix: browser, csv tools 2026-04-08 16:32:26 -07:00
Richard Tang d19cb2843e feat: separate resume and new session flow for queen sessions 2026-04-08 15:45:37 -07:00
Richard Tang ea31b037b8 feat: register browser tools and skills for queen 2026-04-08 15:29:00 -07:00
Richard Tang 5fe924318d Merge remote-tracking branch 'origin/feature/hive-experimental-comp-pipeline' into feat/independent-queen 2026-04-08 15:09:04 -07:00
Bryan 8e6a812ce6 Merge branch 'feature/hive-experimental-comp-pipeline' into feat/open-hive-colony 2026-04-08 15:08:00 -07:00
Bryan 1565fd52e1 feat: add user profile settings and UI enhancements 2026-04-08 15:07:01 -07:00
Bryan 53f5f93deb fix: correct import paths for subscription token detection in BYOK modal 2026-04-08 15:06:05 -07:00
Richard Tang 21afac2b59 feat: use independent mode when pm queen 2026-04-08 14:54:07 -07:00
Timothy c03f1caa58 fix: strip internal tags 2026-04-08 14:52:41 -07:00
Richard Tang a5e928ac95 feat: indenpend phase queen 2026-04-08 14:36:24 -07:00
Timothy 648e3cd52a feat: think out loud 2026-04-08 14:30:24 -07:00
Timothy b216df76a0 fix: character inception 2026-04-08 14:07:58 -07:00
Bryan ddee82eaef Merge branch 'feature/hive-experimental-comp-pipeline' into feat/open-hive-colony 2026-04-08 12:56:50 -07:00
Timothy 6e88bb0205 feat: wire queen dms 2026-04-08 12:56:00 -07:00
Bryan 0aa19721c3 Merge branch 'feature/hive-experimental-comp-pipeline' into feat/open-hive-colony 2026-04-08 12:11:48 -07:00
Timothy cf1e26b012 Merge branch 'feat/open-hive-colony' into feature/hive-experimental-comp-pipeline 2026-04-08 12:08:42 -07:00
Timothy 47e02c0821 Merge branch 'feat/queen-profile' into feature/hive-experimental-comp-pipeline 2026-04-08 12:07:21 -07:00
Bryan 7e1ebf1c26 Merge branch 'feature/hive-experimental-comp-pipeline' into feat/open-hive-colony 2026-04-08 11:50:39 -07:00
Bryan ecbf543e4c Merge branch 'main' into feat/open-hive-colony 2026-04-08 11:50:07 -07:00
Timothy 7daca39bb2 fix: proper skill loading 2026-04-08 11:37:29 -07:00
Aagrim Rautela d8712ceb72 fix(core): add error handling to _dump_failed_request to prevent crashes on read only filesystem (#6036) 2026-04-08 16:51:53 +08:00
Navya Bijoy 5a90a4ba42 update default storage path from /tmp to ~/.hive/agents/{agent_name}/ (#6556) 2026-04-08 16:22:45 +08:00
Emmanuel Nwanguma e69c381331 test(event_bus): add comprehensive unit tests for EventBus (#4826)
* test(event_bus): add comprehensive unit tests for EventBus

- Add 38 tests covering all EventBus functionality
- Test subscription management (subscribe/unsubscribe)
- Test event publishing and delivery to subscribers
- Test filtering by stream, node, and execution
- Test concurrency (handler errors, semaphore limits)
- Test history operations (get_history, get_stats)
- Test wait_for async waiting with timeout
- Test convenience publishers (emit_* methods)
- Test AgentEvent dataclass and EventType enum

Fixes #4782

* test(event_bus): add comprehensive unit tests for EventBus

- Add 41 tests covering all EventBus functionality
- Test subscription management (subscribe/unsubscribe)
- Test event publishing and delivery to subscribers
- Test filtering by stream, node, execution, and graph_id
- Test concurrency (handler errors, semaphore limits)
- Test history operations (get_history, get_stats)
- Test wait_for async waiting with timeout
- Test convenience publishers including new emit_tool_doom_loop
  and emit_escalation_requested methods
- Test AgentEvent dataclass with graph_id field
- Test EventType enum including NODE_TOOL_DOOM_LOOP and ESCALATION_REQUESTED

Fixes #4782

* test(event_bus): add tests for new worker monitoring events

Add tests for newly added emit methods:
- emit_worker_escalation_ticket (judge → queen escalation)
- emit_queen_intervention_requested (queen → operator escalation)
- emit_llm_turn_complete (LLM turn metadata)
- emit_node_action_plan (node planning)

Update test_key_event_types_exist to include:
- WORKER_ESCALATION_TICKET, QUEEN_INTERVENTION_REQUESTED
- LLM_TURN_COMPLETE, NODE_ACTION_PLAN
- WORKER_LOADED, CREDENTIALS_REQUIRED

Total: 45 tests (up from 41)

* test(event_bus): add tests for run_id, subagent_report, and new event types

- Add tests for run_id field in AgentEvent.to_dict()
- Add test for emit_subagent_report convenience method
- Update test_key_event_types_exist with new event types
- Total: 48 tests

* fix(test): remove tests for deleted EventBus methods and fix enum names

Remove test_emit_worker_escalation_ticket and
test_emit_queen_intervention_requested (methods were removed in recent
refactors). Fix WORKER_LOADED -> WORKER_GRAPH_LOADED in enum assertions.

* style: add future annotations import and fix empty assertion in test_emit_node_action_plan
2026-04-08 16:06:53 +08:00
Gaurav Rai 8f608048f9 feat(tools): add Weights & Biases ML experiment tracking integration (#6963)
* feat(tools): add Weights & Biases experiment tracking and model monitoring integration

* style: fix ruff formatting in wandb_tool.py

* feat(tools): add Weights & Biases ML experiment tracking integration

* fix(tools): address CodeRabbit review comments on wandb_tool

* fix(tools): rewrite wandb_tool to use official Python SDK instead of undocumented REST endpoints

* fix(tools): address Hundao review — remove .coverage, switch to GraphQL/httpx, fix wandb_host, add README

* fix(tools): wire filters to GraphQL, validate empty metric_keys, fix line lengths

* fix(tools): check credentials before input validation in wandb_get_run_metrics

Move _get_creds() call before run_id/metric_keys checks so the
framework credential test receives the expected {error, help} response
instead of a bare input-validation error.
2026-04-08 14:57:03 +08:00
Hundao df29c49bd0 fix(test): update queen memory reflection test mocks for litellm format (#6991)
* fix(test): update queen memory test mocks to match litellm ModelResponse format

PR #6976 refactored reflection_agent to extract tool calls from litellm
ModelResponse objects (choices[0].message.tool_calls) instead of plain
dicts. The test mocks were not updated, causing tool calls to silently
fail and two tests to break.

Fixes #6990

* style: ruff format
2026-04-08 14:44:11 +08:00
Timothy b3759db83b refactor(hive): home hive dir structure 2026-04-07 19:21:16 -07:00
Bryan 8308207be8 feat: add light mode support for flowchart, sub-agent panes, and normalize settings modal sizing 2026-04-07 19:11:54 -07:00
Timothy 6b86c602c7 Merge branch 'main' into feature/hive-experimental-comp-pipeline 2026-04-07 18:49:14 -07:00
Bryan d9644eaa39 chore: remove old workspace GUI and dependencies 2026-04-07 18:45:56 -07:00
Bryan 3976ea6934 feat: add home redesign, credentials, and org chart pages 2026-04-07 18:45:38 -07:00
Bryan cc00ae8999 feat: add colony chat and queen DM pages 2026-04-07 18:45:20 -07:00
Bryan 70bf337c03 refactor: update graph and subagent display components 2026-04-07 18:44:55 -07:00
Bryan 6ecdbf47b0 feat: add model switcher, settings modal, and template card 2026-04-07 18:44:07 -07:00
Bryan e0e1abbb64 feat: add sidebar and header components 2026-04-07 18:43:48 -07:00
Bryan cb8c26ee18 feat: add app layout, routing, and global styles 2026-04-07 18:43:25 -07:00
Bryan 3d6beca577 feat: add config and credential API client endpoints 2026-04-07 18:43:05 -07:00
Bryan bed9670395 feat: add colony types, registry, and context providers 2026-04-07 18:42:46 -07:00
Bryan 61bb0b6594 refactor: update session, credential routes and runner 2026-04-07 18:42:25 -07:00
Bryan e7506fcd25 feat: add runtime config and model switching API 2026-04-07 18:42:08 -07:00
Timothy 7cc92eb8c3 fix: queen session start prompt 2026-04-07 18:00:15 -07:00
Timothy 3a70243b82 refactor(queen): use new infra 2026-04-07 17:50:45 -07:00
Richard Tang b701605a62 feat: update queen profile apis 2026-04-07 17:10:42 -07:00
Timothy a5b17a293b refactor: simplify agent loading 2026-04-07 17:03:12 -07:00
Richard Tang 7ad25d986b feat: queen profile 2026-04-07 16:49:34 -07:00
Timothy db572b9be6 fix: mcp registry pipeline stage 2026-04-07 16:15:40 -07:00
Timothy 0ee653a164 fix: agent loading pipeline 2026-04-07 15:20:31 -07:00
RichardTang-Aden c92662bdb1 Merge pull request #6976 from aden-hive/feat/simplify-queen-memory
Simplify queen memory: remove colony memory, keep global only
2026-04-07 13:58:26 -07:00
Richard Tang 19469ff404 chore: lint format 2026-04-07 13:57:05 -07:00
Richard Tang 7fcb51985d fix: edge case for memory recall 2026-04-07 13:55:18 -07:00
Timothy 3c9911c25b refactor: grand clean-up 2026-04-07 13:42:39 -07:00
Richard Tang 3dbd20040a fix: reflection agent runner 2026-04-07 13:07:41 -07:00
Richard Tang c9d62139af feat: add extra logging 2026-04-07 12:45:37 -07:00
Timothy 172b180477 refactor: remove deprecated shims 2026-04-07 12:28:19 -07:00
Richard Tang 6637bc8d96 feat: simplify memory implementation 2026-04-07 12:08:35 -07:00
Timothy 93dc35dcbb refactor(architecture): revamp 2026-04-07 09:19:03 -07:00
Richard Tang 30ad3edfbf docs: readme improvement 2026-04-07 09:18:34 -07:00
Timothy d10912be15 feat: key pool 2026-04-06 16:53:55 -07:00
Timothy b9c5191059 chore: update gitignore 2026-04-06 16:49:13 -07:00
Bryan @ Aden d9037172d8 Merge pull request #6898 from sundaram2021/fix/ast_pow_ddos_mitigation
micro-fix(security): mitigate ast.Pow DoS and enforce safe_eval timeout
2026-04-06 13:36:03 -07:00
Richard Tang df41732e95 chore: enhance log for LLM 2026-04-06 13:30:31 -07:00
Richard Tang cd9a625041 fix: dynamic absolute path and instruction 2026-04-06 13:21:06 -07:00
Richard Tang 420d703138 fix: quickstart extension instructions 2026-04-06 13:11:10 -07:00
Richard Tang 66866e524d fix: remove old new agent button 2026-04-06 13:05:03 -07:00
Rodrigo M.V.S. 33e6c018a3 docs: Add Frontend Dev Workflow subsection to CONTRIBUTING.md (#6523)
* chore: update package-lock.json after npm install

* fix: export validate_agent_path from server module

* fix: remove circular import in server module

* docs: Add Frontend Dev Workflow subsection to CONTRIBUTING.md

* chore: revert accidental package-lock.json changes

* docs: clarify frontend dev requires both backend and dev server

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-04-06 23:16:29 +08:00
Akash 1ac50ab532 feat: add theme toggle and tab improvements (#6062)
Co-authored-by: Akash Kumar <akash369kumar369@gmail.com>
2026-04-06 23:02:26 +08:00
Aashutosh Pandey 4df924d3d7 fix(security): prevent error_middleware from leaking internal exception details to HTTP clients (#6903)
The error_middleware was returning str(e) and type(e).__name__ directly
in JSON responses, which could expose file paths, database connection
strings, API key names, and internal class names to untrusted clients.

Changes:
- Return generic 'Internal server error' message instead of raw exception
- Improve server-side log to include request method and path
- Add unit tests verifying no internal details are leaked

The full exception traceback remains available via logger.exception()
for server-side debugging.

Co-authored-by: Aashutosh Pandey <aashutoshpandey@Aashutoshs-MacBook-Air.local>
2026-04-06 22:50:43 +08:00
Sujan Kumar MV 8f2d87cc5d docs(tools): add README for 10 tools (batch 3) (#6913)
* docs(tools): add README for 10 tools (batch 3)

Adds README.md for: supabase_tool, zoom_tool, twitter_tool,
twilio_tool, shopify_tool, snowflake_tool, zendesk_tool,
yahoo_finance_tool, youtube_transcript_tool, docker_hub_tool

Partial fix for #6486

* docs(shopify): fix fulfillment_status value and body_html param name

- fulfillment_status example: "unfulfilled" is not a valid Shopify API
  value, changed to "unshipped"
- tool table: "description" is not the actual param name, it's body_html

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-04-06 22:10:22 +08:00
Leayx 4b795584f6 micro-fix(quickstart): correct npm invocation in powershell script (#6816)
- powerShell supports direct command invocation without requiring the call operator "&"
- the script used the call operator "&" to invoke `npm install` and `npm run build`
- which caused incorrect command parsing in PowerShell when combined with output redirection "2>&1"
- This resulted in unexpected errors such as "Unknown command: pm", even though the commands worked correctly when executed manually

- removed the unnecessary use of the call operator "&" and invoked npm commands directly
- npm commands now execute correctly within the script, aligning with standard PowerShell behavior and eliminating the parsing issue
2026-04-06 21:56:59 +08:00
Faryal Rzwan 6024ae4241 docs(tools): add README for 9 tools (batch 1) (#6881)
* docs(tools): add README for huggingface, jira, pinecone, langfuse, linear, mongodb, redis, vercel, confluence

* docs(tools): fix review comments in confluence, mongodb and vercel READMEs

* docs(mongodb): add MONGODB_DATA_SOURCE to setup section

The code uses os.getenv("MONGODB_DATA_SOURCE") in every API request
body as the dataSource field. Without it, requests send an empty
dataSource and fail. Add it back to the setup section.

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-04-06 21:44:51 +08:00
Hundao aaa5d661c3 fix(ci): unbreak main - playwright deps + framework test suite (#6955)
* fix(tools): move playwright back to main dependencies

playwright was moved to the browser extra in c7e85aa9 as part of the GCU
refactor to use a browser extension. But web_scrape_tool still imports
playwright at module level and requires it unconditionally, so CI's
Test Tools job breaks with ModuleNotFoundError.

web_scrape_tool has no fallback without playwright — it's a hard
dependency, not optional. Put it back in main deps.

Fixes CI failure on Test Tools (ubuntu-latest).

* chore: remove dead test_highlights.py script

tools/test_highlights.py is orphaned from the GCU refactor in c7e85aa9:

- imports highlight_coordinate and highlight_element from gcu.browser.highlight,
  but highlight.py was deleted in that refactor
- calls BrowserSession.start(), open_tab(), get_active_page(), stop() — none
  of these methods exist on the current BrowserSession class

The script can't run at all, and it's tripping ruff's I001 import-order
check (fail on Lint CI after cache invalidation).

* test: fix browser/refs tests broken by GCU refactor

Tests were still testing the old Playwright-based API after c7e85aa9
moved GCU to an extension-bridge architecture.

test_refs.py (6 tests):
  Refs system now produces CSS selectors like
  [role="button"][aria-label="Submit"]:nth-of-type(1) for the bridge's
  DOM matcher, instead of Playwright's role=button[name="Submit"] >> nth=0.
  Updated expected values to match. Renamed test_escapes_quotes_in_name to
  test_quoted_name_passes_through and added a comment noting that inner
  quotes aren't currently escaped (follow-up concern).

test_browser_tools_comprehensive.py (4 tests):
  - test_screenshot_full_page: browser_screenshot passes selector=None
    when no selector is provided; update assertion.
  - test_file_upload: browser_upload validates file paths exist on disk.
    Create real tmp files and mock the CDP calls it makes.
  - test_evaluate_with_bare_return: renamed to
    test_evaluate_passes_script_through_to_bridge. IIFE wrapping lives
    in bridge.evaluate, not in the browser_evaluate tool — mocking the
    bridge bypasses the wrapping logic, so the tool just passes the
    script through.
  - test_evaluate_complex_script: browser_evaluate returns bridge's raw
    result (no 'ok' wrapper); check for 'result' key instead.

test_browser_advanced_tools.py (deleted):
  The whole file patched get_session and page.wait_for_function (the old
  Playwright-based API). The bug it guarded against (user text interpolated
  into a JS source string) is architecturally impossible in the new
  bridge-based tools, which send text via structured RPC. Coverage for
  browser_wait exists in test_browser_tools_comprehensive.py.

* test(core): fix event_loop tests broken by hive-v1 refactor

Several framework tests were left failing or hanging after the hive-v1
refactor landed. This un-breaks CI without touching production code.

- Worker auto-escalation: 8 tests were hanging because EventLoopNode
  with event_bus treats non-queen/non-subagent nodes as workers and
  auto-escalates to queen, then blocks on _await_user_input forever
  (no queen in standalone tests). Opt out via is_subagent_mode=True.
- MockConversationStore: added clear() to match the production store
  (storage/conversation_store.py), which event_loop_node.py:425 calls.
- Executor output semantics: result.output now only contains terminal-
  node outputs; two handoff tests now read intermediate outputs from
  result.session_state["data_buffer"].
- Restore filter: test_restore_from_checkpoint needs set_current_phase
  so restore()'s phase_id filter matches.
- Removed two _build_context tests whose target method no longer exists
  (replaced by standalone build_node_context()). Remaining execution_id
  coverage is adequate in TestExecutionId + integration tests.

* style: ruff format + drop em dash in comment

* test(core): fix remaining framework tests broken by hive-v1 refactor

Rounds out the fix started in the previous commit. Full framework
suite now passes (1589 passed, 0 failed).

- conftest.py: force-bind framework.runner submodules (mcp_registry,
  mcp_client, mcp_connection_manager) as attributes on the parent
  package. Without this, pytest monkeypatch.setattr with dotted-string
  paths fails because the attribute walker can't resolve the submodule
  even though __init__.py imports from it. Affects ~25 MCP tests.
- test_queen_memory: _execute_tool() grew a required caller kwarg for
  worker type-restrictions. Pass caller="queen" so path-traversal
  checks run without caller restrictions interfering.
- test_session_manager_worker_handoff: _subscribe_worker_digest was
  removed in the refactor, dropped the dead monkeypatches.
- test_skill_context_protection: NodeConversation now reads _run_id
  in add_tool_result(), so the __new__-based test helper has to
  initialise it.
- test_node_conversation: restore() now filters parts by run_id for
  crash recovery. Renamed the stale test and flipped the assertion
  to match the new filtering semantics.
- test_tool_registry: CONTEXT_PARAMS was updated (workspace_id out,
  profile in). Switched the test's example stripped params.

* docs: drop circular PR reference in test_refs comment

Addresses CodeRabbit nitpick. The comment referenced the PR that was
adding the comment, which becomes a self-reference after merge.
2026-04-05 14:21:32 +08:00
Emmanuel Nwanguma 2e5670ace6 docs(tools): add README for 11 tools (batch 2 of 2) (#6887)
Partial fix for #6486

Add README.md for: n8n_tool, obsidian_tool, pagerduty_tool,
pipedrive_tool, plaid_tool, powerbi_tool, quickbooks_tool,
salesforce_tool, sap_tool, terraform_tool, tines_tool
2026-04-05 10:00:14 +08:00
Emmanuel Nwanguma 634658e829 docs(tools): add README for 11 tools (batch 1 of 2) (#6886)
Partial fix for #6486

Add README.md for: aws_s3_tool, azure_sql_tool, cloudinary_tool,
duckduckgo_tool, file_system_toolkits, gitlab_tool,
google_search_console_tool, greenhouse_tool, hubspot_tool,
kafka_tool, microsoft_graph_tool
2026-04-05 09:52:47 +08:00
Richard Tang dc64cc68a1 feat: add a html file for browser extension instruction 2026-04-03 21:52:42 -07:00
RichardTang-Aden e8d56c815d Merge pull request #6905 from aden-hive/feature/hive-v1
Release / Create Release (push) Waiting to run
Release v0.9.0 — Browser Extension, Queen Memory v2 & Graph Executor Refactor
2026-04-03 21:13:16 -07:00
Richard Tang cc21780e99 test: ignore dummy agent in make 2026-04-03 20:49:12 -07:00
Richard Tang 714de59d2a test: ignore e2e dummy agent tests 2026-04-03 20:47:27 -07:00
Richard Tang ed8d417bef chore: ruff lint 2026-04-03 20:31:14 -07:00
Richard Tang 294df7f066 Merge remote-tracking branch 'origin/main' into feature/hive-v1 2026-04-03 20:21:53 -07:00
Richard Tang b46fe69712 fix: handle KIMI pause turn 2026-04-03 20:21:28 -07:00
Timothy 6e6efb97bd fix: close browser before finishing 2026-04-03 20:12:01 -07:00
Timothy dc4be4f906 fix: remove standalone gcu tool registry 2026-04-03 19:32:54 -07:00
Timothy 9cb20986d2 Merge branch 'feature/queen-lifecycle' into feature/hive-v1 2026-04-03 19:22:58 -07:00
Timothy 7d75c6a09f fix: queen lifecycle 2026-04-03 19:22:45 -07:00
Timothy aab38222db fix: legacy import 2026-04-03 19:16:00 -07:00
Timothy c46082780f feat: gcu extra learnings 2026-04-03 18:45:34 -07:00
Richard Tang ff8123acb9 fix: browser log path 2026-04-03 18:33:14 -07:00
Richard Tang 358b4e1bf2 Merge remote-tracking branch 'origin/feature/hive-v1' into feature/hive-v1 2026-04-03 18:29:31 -07:00
Richard Tang 934c424510 feat: handle gemini tool call tags 2026-04-03 18:29:04 -07:00
Bryan f12db37d75 fix: quickstart launch chrome extensions page 2026-04-03 18:27:48 -07:00
Richard Tang 2b263f6e10 feat: move thinking tags handling on frontend 2026-04-03 18:20:45 -07:00
Timothy 4513f5dcd7 fix: capture random errors 2026-04-03 18:12:29 -07:00
Timothy 1fabd8e8fb fix: queen phase incubating -> editing 2026-04-03 18:00:21 -07:00
Richard Tang 043c79e0e4 feat: add detailed LLM response logging to reflection loop 2026-04-03 17:43:41 -07:00
Timothy 9193336fd3 fix: browser quickstart 2026-04-03 17:40:53 -07:00
Richard Tang 59e90d3168 fix: track reflection reason 2026-04-03 17:20:13 -07:00
Timothy ef34b1190a Merge branch 'feature/browser-extension-quickstart' into feature/hive-v1 2026-04-03 17:19:07 -07:00
Timothy 1e848d67bb feat: browser extension setup guide 2026-04-03 17:18:53 -07:00
Richard Tang a0e68871f7 feat: strengthen worker termination 2026-04-03 17:06:19 -07:00
Richard Tang 767beb4005 Merge branch 'feature/colonized-memory' into feature/hive-v1 2026-04-03 16:10:45 -07:00
Richard Tang 95655a4c85 feat: better reflection tracking 2026-04-03 16:09:46 -07:00
Timothy 102866780c fix: browser tools 2026-04-03 15:47:54 -07:00
Richard Tang a379ae97c8 feat: auto-escalate worker text-only turns to queen after grace period 2026-04-03 15:46:23 -07:00
Timothy d5ae7e6c4b fix: turn ms 2026-04-03 15:26:44 -07:00
Timothy 68f6b72564 Merge branch 'refactor/automated-testing' into feature/hive-v1 2026-04-03 15:09:13 -07:00
Timothy eecfb4f407 Merge branch 'feature/colonized-memory' into refactor/automated-testing 2026-04-03 14:43:41 -07:00
Timothy 32f556cd6e feat: incubating phase 2026-04-03 14:43:05 -07:00
Richard Tang 8ea026508d fix: scope conversation restore to current run_id 2026-04-03 13:42:13 -07:00
Richard Tang 771efd5ce4 feat: simplify worker reflection 2026-04-03 13:03:47 -07:00
Timothy 8f56b8b068 feat: verified testing 2026-04-03 13:00:49 -07:00
Richard Tang 4f588b3010 fix: remove outdated memory cursor design 2026-04-03 12:38:05 -07:00
Richard Tang 9f70868f98 feat: include v1 memory in migration and keep the diary writing in v2 2026-04-03 11:38:30 -07:00
Richard Tang 6449c76091 refactor: remove old worker digest 2026-04-03 11:20:22 -07:00
Richard Tang b328ced110 fix: remove bounded polling loop that killed forever-alive graphs after ~100s 2026-04-03 10:16:34 -07:00
Richard Tang 1b6e8c34be fix: queen revive drops user input and missing skill protocols 2026-04-03 10:01:13 -07:00
Timothy 674454cc5b Merge branch 'feature/colonized-memory' into refactor/automated-testing 2026-04-03 09:58:03 -07:00
Timothy 59c3979451 feat: auto tests 2026-04-03 09:57:40 -07:00
Richard Tang 51fdd93f0c fix: queen session and node registry 2026-04-03 09:11:33 -07:00
Timothy 95f1d1abcd feat: browser automated test 2026-04-03 07:31:10 -07:00
Richard Tang a164ed6faf strengthen the logging 2026-04-02 20:03:53 -07:00
Richard Tang abe3d2d067 feat: add debugger information 2026-04-02 17:54:42 -07:00
Richard Tang c80d86bdbe fix: missing restored pending input 2026-04-02 17:12:09 -07:00
Richard Tang ec08ae7438 feat: worker agent memory 2026-04-02 17:05:32 -07:00
Timothy e0cd16b92b fix: trailing white spaces 2026-04-02 16:43:23 -07:00
Richard Tang 4006ee96b6 feat: add dummy agent smoke test 2026-04-02 16:29:00 -07:00
Richard Tang b78c879404 feat: context rewiring 2026-04-02 16:01:06 -07:00
Timothy 71a71beca7 feat: extension browser tools 2026-04-02 15:58:52 -07:00
Richard Tang c5052ade34 feat: consolidate context building 2026-04-02 15:54:16 -07:00
Richard Tang e1911b3684 refactor: deprecated client facing node 2026-04-02 15:09:26 -07:00
Richard Tang 45cfae5217 feat: add hive llm healthcheck 2026-04-02 14:50:05 -07:00
RichardTang-Aden 4d877469d5 Merge pull request #6195 from Sri-Likhita-adru/fix/stream-transient-retry-cap
fix(llm): separate retry counters in stream() - transient errors used wrong cap
2026-04-02 14:01:00 -07:00
Richard Tang 96c7070cc9 fix: restore dummy agent smoke tests 2026-04-02 13:29:57 -07:00
Richard Tang 6affe06f6d feat: add kimi support for dummy agent test 2026-04-02 13:12:09 -07:00
Richard Tang 02edd44283 feat: First-Class Worker Agents with Event-Driven Dependency Execution 2026-04-02 13:00:52 -07:00
Richard Tang 60d094464a feat: robust run id 2026-04-02 12:35:16 -07:00
Timothy c7e85aa9f5 fix: redo gcu tools for extension based browser use 2026-04-02 12:07:24 -07:00
Richard Tang 00c55d5fb2 refactor: remove unused edge code 2026-04-02 12:02:51 -07:00
Richard Tang 6a7778ebcd refactor: remove orphaned client code 2026-04-02 12:00:59 -07:00
Timothy 8f042b7ca5 feat: browser extension 2026-04-02 11:59:57 -07:00
Richard Tang b594165575 feat: fresh worker context per run 2026-04-02 11:43:14 -07:00
Timothy 1630c1ee7a feat: add tab and CDP methods to browser bridge
Added methods to control tabs via the Chrome extension:
- create_tab(groupId, url) - create and navigate tabs in user's Chrome
- close_tab(tabId) - close tabs
- list_tabs(groupId?) - list tabs
- cdp_attach(tabId) - attach CDP for automation
- cdp_send(tabId, method, params) - send CDP commands

These enable browser automation through the extension when Playwright
can't connect directly to the user's Chrome.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:09:07 -07:00
Richard Tang 318ecfd508 refactor: refactor shared memory to data buffer 2026-04-02 11:02:30 -07:00
Timothy 08b0cbc208 fix: inherit storage state from user's Chrome when connected via CDP
When Playwright connects to the user's Chrome via CDP (bridge connected),
we now copy cookies/storage from an existing browser context into the
new agent context. This preserves login sessions (LinkedIn, etc.).

Before: New context created fresh → no cookies → login wall
After:  New context inherits storage state → cookies preserved → logged in

Requires Chrome to be started with --remote-debugging-port=9222 or
HIVE_BROWSER_CDP_URL to be set for this to work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:45:53 -07:00
Timothy 4bcbebf761 fix: subagent browser sessions persist across calls
Two fixes for browser session persistence:

1. Use stable profile name (agent_id only, not agent_id-subagent_instance)
   - Before: "honeycomb_linkedin_outreach-gcu-scan-profiles-1" (unique each call)
   - After: "gcu-scan-profiles" (stable across calls)

2. Remove browser_stop() call in finally block
   - Keeping browser alive allows cookies/auth to persist
   - Browser cleaned up when parent agent stops or explicitly requested

This fixes the issue where LinkedIn auth was lost between subagent runs
because each run created a fresh browser profile with no cookies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:44:21 -07:00
Timothy 00a3f94315 fix: browser tools use subagent profile from context
Changed all browser tool `profile` parameters from defaulting to "default"
to defaulting to None. This allows `get_session()` to use the context
variable set by `set_active_profile()` in the subagent executor.

Before: Subagent calls browser_navigate() → profile="default" → tab group named "default"
After:  Subagent calls browser_navigate() → profile=None → get_session() uses contextvar → tab group named "{agent_id}-{subagent_id}"

Fixes tab groups being named "default" instead of the subagent's name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:36:30 -07:00
Richard Tang 5b08edb384 Remove unused subagent escalation receiver 2026-04-02 10:32:33 -07:00
Richard Tang 332311b49b refactor: graph executer cleanup 2026-04-02 10:20:46 -07:00
Richard Tang 0e5f571b09 refactor: removed unused ticket escalate and unused runner code 2026-04-01 20:10:41 -07:00
Richard Tang bf06984625 refactor: remove deprecated cli command and orchastrator 2026-04-01 19:58:54 -07:00
Richard Tang d4875892fc chore: remove temp docs 2026-04-01 19:45:59 -07:00
Richard Tang 86f6aa2e8f refactor: remove deprecated functions in runner and runtime 2026-04-01 19:43:29 -07:00
Richard Tang 537667758a refactor: remove worker input and worker session 2026-04-01 19:16:38 -07:00
Timothy 76fe644cac chore: fix lint 2026-04-01 19:06:40 -07:00
Timothy c6c333761b fix: lint errors in compaction module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:06:05 -07:00
Richard Tang 6a77a9a7b2 refactor: rename worker functions for clarity 2026-04-01 19:04:44 -07:00
Richard Tang 1a37fb2f36 tests: add tests to memory functions 2026-04-01 17:41:25 -07:00
Richard Tang 2609ca7619 fix: memory call bugs 2026-04-01 17:38:53 -07:00
Richard Tang b7a115259d fix: outdated tests 2026-04-01 17:33:17 -07:00
Richard Tang 1765e1cb6c feat: debugger and simplication 2026-04-01 17:28:54 -07:00
Timothy 0417e33ab2 fix: batch modify gmail tool 2026-04-01 17:23:57 -07:00
Timothy 42a9c7b0f1 fix: structural compaction guardrail 2026-04-01 17:03:13 -07:00
Timothy 398e86d787 fix: memory recall type bug 2026-04-01 16:43:18 -07:00
Timothy 51406e358e Merge branch 'feature/new-compaction' into feature/hive-v1 2026-04-01 16:06:56 -07:00
Timothy 137162eada feature: improve micro compaction 2026-04-01 16:06:35 -07:00
Timothy 2b7a38f746 feat: tagged queen memory 2026-04-01 15:13:49 -07:00
Richard Tang b25de61363 feat(wip): new queen memory 2026-04-01 15:03:21 -07:00
Sundaram Kumar Jha 6022f6c911 refactor: bit estimation formula 2026-04-02 00:44:50 +05:30
Sundaram Kumar Jha dacda3337f test(safe_eval): cover alarm state preservation 2026-04-02 00:12:15 +05:30
Sundaram Kumar Jha 267f797abc fix(security): preserve host alarm state in safe_eval 2026-04-02 00:12:03 +05:30
Sundaram Kumar Jha 42fd1ec8d1 chore: formatted 2026-04-01 23:47:37 +05:30
Sundaram Kumar Jha 81774d5d0e test(safe_eval): cover execution timeout behavior 2026-04-01 23:36:14 +05:30
Richard Tang b3adbe745f chore: ruff lint 2026-04-01 11:05:53 -07:00
Sundaram Kumar Jha d1cbfd1e54 fix(security): enforce safe_eval execution timeout 2026-04-01 23:35:41 +05:30
Richard Tang f3fefe0cbc refactor: remove adapt md and its reference 2026-04-01 11:00:41 -07:00
Sundaram Kumar Jha fd71501215 test(safe_eval): add ast.Pow DoS regression coverage 2026-04-01 23:29:02 +05:30
Sundaram Kumar Jha 406bfb23b9 fix(security): bound ast.Pow in safe_eval 2026-04-01 23:28:57 +05:30
Bryan @ Aden c8a25a0287 Merge pull request #6658 from saurabhiiitm062/feat/cloudflare-dns-tool
feat: cloudflare DNS/Zone tool integrations
2026-04-01 10:11:44 -07:00
Hundao 5823513fde fix: propagate contextvars to tool executor threads (#6854)
* fix: propagate contextvars to tool executor threads

run_in_executor does not propagate contextvars to worker threads,
causing execution context params like data_dir to be lost when MCP
tools are called. This made save_data, serve_file_to_user, and other
tools that depend on auto-injected data_dir fail with "Missing
required argument: data_dir".

Fix: use contextvars.copy_context().run() to carry the current context
into the thread pool worker.

* test: regression test for contextvars propagation in tool executor

Verifies that execution context (data_dir, etc.) set via
set_execution_context is visible inside tool executors that run
in thread pool workers via run_in_executor.
2026-04-01 19:38:41 +08:00
Hundao 97ce8dfc54 fix: skip executable permission check on Windows in skill validator (#6894)
Windows has no POSIX executable bits, so the stat-based check always
fails. Skip the check on Windows in the validator, and mark the two
related tests as POSIX-only. Unix CI still catches non-executable
scripts from Windows contributors.

Fixes #6893
2026-04-01 19:37:18 +08:00
Gaurav Rai 5e628c7606 test(core): increase tool_registry coverage from 47% to 69% (#6818)
* test(core): increase tool_registry coverage from 47% to 69%

Add 19 new tests covering previously untested paths in ToolRegistry:

- register_function: type hint inference (int/float/bool/dict/list),
  required vs optional params, custom name/description, docstring fallback,
  executor delegation
- discover_from_module: @tool decorator pickup, missing-file zero-count,
  TOOLS dict without tool_executor uses mock executor
- has_tool / get_registered_names basic assertions
- Session context injection into MCP tool calls via set_session_context()
- Execution context override (contextvars) wins over session context
- _convert_mcp_tool_to_framework_tool: strips CONTEXT_PARAMS from both
  properties and required lists
- load_mcp_config: list format, dict format, graceful invalid-JSON warning
- resync_mcp_servers_if_needed: returns False with no clients; returns
  False when credentials and ADEN_API_KEY are unchanged

Coverage: 47 % → 69 % (+22 pp), all 31 tests pass, ruff clean.

Relates to #1972

* style: fix formatting in test_tool_registry.py

* fix: accept **kwargs in fake_load_registry to match updated signature

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-04-01 19:10:04 +08:00
Harsh Gajjar 5b931982e3 feat(tools): add Freshdesk helpdesk integration (#6099)
* feat(freshdesk): add Freshdesk tool integration with credentials and API functionality

- Introduced Freshdesk tool for managing tickets, contacts, agents, and groups via Freshdesk API v2.
- Added Freshdesk credentials handling in `credentials/freshdesk.py`.
- Registered Freshdesk tools in `tools/freshdesk_tool/__init__.py` and `tools/freshdesk_tool/freshdesk_tool.py`.
- Updated `__init__.py` files to include Freshdesk in the exports.
- Created comprehensive README for Freshdesk tool usage and setup.
- Implemented unit tests for Freshdesk tool functionality.

All tests pass, and code adheres to ruff linting and formatting standards.

* refactor(freshdesk_tool): simplify _get_domain logic

- remove unnecessary try/except around credentials.get("freshdesk_domain")
- directly return stripped credential value if present
- fallback to FRESHDESK_DOMAIN env variable when missing
- eliminate unreachable code while preserving behavior

* refactor(freshdesk_tool): replace dynamic httpx dispatch in _request

- replace getattr(httpx, method) with explicit handling for get, post, and put
- raise ValueError for unsupported HTTP methods
- preserve existing status handling and response parsing logic

* docs(freshdesk): improve credential and error handling documentation

- add docstrings for error handling helpers in freshdesk_tool
- document purpose and usage of freshdesk credential specs
- improve clarity around error response structure and handling
2026-04-01 18:28:32 +08:00
Bhuvaneswari N 8174f330ae docs: document 5 new natively supported LLM providers (#6865) 2026-04-01 18:18:15 +08:00
Rohit Singh 9774e53720 feat(runtime): add idempotency key support to trigger() (#6710) 2026-04-01 18:03:31 +08:00
Timothy @aden cf3296984c Merge pull request #6888 from aden-hive/fix/python-test
Release / Create Release (push) Waiting to run
fix(micro-fix): python test
2026-03-31 19:02:23 -07:00
Timothy eafbeb78b4 fix: python test 2026-03-31 18:55:24 -07:00
Timothy 5cb5083f8d fix(micro-fix): queen skill allowlist 2026-03-31 18:52:45 -07:00
Bryan @ Aden bf86daee92 Merge pull request #6319 from KartikPawade/fix/sap-tool-credential-store
fix: use CredentialStoreAdapter in sap_tool instead of raw os.getenv()
2026-03-31 18:30:21 -07:00
Timothy 43bbd0f31f feat(micro-fix): skill cli parser 2026-03-31 18:13:01 -07:00
Timothy @aden 2cf962b538 Merge pull request #6782 from levxn/skills/cli-commands
feat(skills): implement hive skill CLI subcommands (CLI-1 through CLI-13)
2026-03-31 17:59:25 -07:00
Timothy 4298196700 Merge branch 'main' into feature/agent-skills 2026-03-31 17:53:57 -07:00
Timothy @aden bc1f712e42 Merge pull request #6610 from levxn/skills/ds-ovrride-heuristics
feat(skills): DS-12 and DS-13 — config override application, batch auto-detection, and context preservation warning
2026-03-31 17:51:19 -07:00
Timothy @aden cccbcc8ec3 Merge pull request #6529 from vakrahul/fix/mcp-structured-errors
feat: structured MCP error codes and failure diagnostics (closes #6352)
2026-03-31 17:40:50 -07:00
Timothy @aden 0722f83f16 Merge pull request #6792 from fermano/feat/agent-selection-tool-resolution-n-framework-integration
Feat/agent selection tool resolution n framework integration
2026-03-31 17:38:39 -07:00
saurabhiiitm062 ebb6605a86 fix: address Cloudflare review comments (DDoS, pagination, validation, tests) 2026-03-31 22:23:51 +05:30
Hundao 72091d2783 fix(security): add SSRF protection to web_scrape tool (#6879)
Validate URLs against internal network ranges before making requests.
Block private IPs, loopback, link-local, and cloud metadata endpoints
(169.254.169.254). Intercept Playwright navigation to catch redirect-based
SSRF bypasses.

Fixes #1157

Co-authored-by: Harshit <Harshitk-cp@users.noreply.github.com>
2026-03-31 14:04:47 +08:00
Kartik 3bb69a5784 fix: add env fallback and type hints for SAP tool credentials
Made-with: Cursor
2026-03-31 11:15:41 +05:30
Kartik 63fb089062 chore: format sap_tool.py
Made-with: Cursor
2026-03-31 10:21:56 +05:30
Hundao d5ba985e29 docs: fix agent.json examples to match current schema (#6878)
Replace outdated node_id/edge_id with id, wrap nodes/edges under
graph key, add goal section with success_criteria. Matches what
load_agent_export() and NodeSpec actually expect.

Fixes #897

Co-authored-by: Jose37456 <Jose37456@users.noreply.github.com>
2026-03-31 12:41:59 +08:00
Bryan @ Aden 6ee510d2f6 Merge pull request #6855 from Ttian18/feat/tina/docs-mcp-unix-sse-transport
docs: add Unix socket and SSE transport to MCP Integration Guide (#6739)
2026-03-30 18:46:01 -07:00
Bryan @ Aden 45b350e7c8 Merge pull request #6857 from Ttian18/feat/tina/job-hunter-pdf-resume
feat(job-hunter): support PDF resume input via file path (#6740)
2026-03-30 18:45:36 -07:00
Bryan @ Aden 7e690de12f Merge pull request #6844 from sundaram2021/fix/quickstart-credentials-in-windows
micro-fix:  shell config handling and add antigravity option
2026-03-30 17:20:36 -07:00
Hundao ae85d2bf59 fix(security): prevent path traversal in session_store (#6876)
Validate that resolved session path stays within the sessions directory
using Path.is_relative_to(). Prevents session_id values like
"../../something" from escaping the sandbox.

Also guard the caller in _write_run_event where get_session_path is
called outside the existing OSError try/except block.

Fixes #1000

Co-authored-by: Sidhartha kumar <Alearner12@users.noreply.github.com>
2026-03-30 23:53:23 +08:00
Juttiga Bheemeswar e9fd0158b9 fix(csv_sql): prevent SQL injection via DuckDB parameter binding (#1408)
* fix(csv_sql): prevent SQL injection via DuckDB parameter binding

* test(csv_sql): add regression test for apostrophe path

* Refactor CSV query function for security and clarity

Removed detailed docstring arguments and return information for the CSV query function. Improved security checks for SQL queries.

* fix/1256-csv-sql-safe-path

Added security regression tests to reject non-SELECT queries and multi-statement queries.

* docs: restore csv_sql docstring (Args, Returns, Examples)

* fix: use word-boundary regex for SQL keyword detection

Substring matching caused false positives on column names like
created_at, updated_at, deleted_at. Switch to \b word-boundary regex.
Also add tests for comment rejection, CTE queries, and keyword-in-column-name.

---------

Co-authored-by: Juttiga Bheem <BBemail@gmail.com>
Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-03-30 23:26:07 +08:00
Zhang 9a68a5d7ee fix(job-hunter): align intake node client_facing and input_keys with agent.json 2026-03-29 11:33:57 -07:00
Zhang 33edf4a207 feat(job-hunter): support PDF resume input via file path (#6740) 2026-03-29 11:26:35 -07:00
Zhang f9fdaf5adc docs: clarify required vs optional fields for Unix and SSE transports 2026-03-29 10:29:47 -07:00
Zhang eabb17934c docs: add Unix socket and SSE transport types to MCP Integration Guide 2026-03-29 10:24:57 -07:00
kernel_crush eba7524955 refactor: remove deprecated storage/backend.py (267 lines) (#6849)
* refactor: remove deprecated storage/backend.py (267 lines)

Delete the fully deprecated FileStorage class and inline its 5 still-active
methods (_validate_key, _load_run_sync, _load_summary_sync, _delete_run_sync,
_list_all_runs_sync) directly into ConcurrentStorage.

Changes:
- Delete core/framework/storage/backend.py (267 lines of no-op/deprecated code)
- Inline active read methods into ConcurrentStorage (no new FileStorage dep)
- Remove deprecated index operations (get_runs_by_goal, get_runs_by_status,
  get_runs_by_node, list_all_goals) and their associated locking
- Update __init__.py to export ConcurrentStorage instead of FileStorage
- Update runtime/core.py to use ConcurrentStorage directly
- Fix Runtime.end_run() to call save_run_sync() (sync wrapper) instead of
  the async save_run(), which was silently dropping the coroutine
- Update test_path_traversal_fix.py to test ConcurrentStorage._validate_key()
- Clean up test_storage.py — remove all FileStorage test classes, un-skip
  ConcurrentStorage tests now that it's self-contained
- Remove stale FileStorage references from testing/test_storage.py docstring,
  testing/debug_tool.py docstring, and test_runtime.py skip reasons

All 44 tests pass, ruff check and ruff format clean.

Fixes #6797

* fix(core): address CodeRabbitAI PR review feedback

 - Fix critical no-op in ConcurrentStorage._save_run_sync by implementing atomic persistence to 
uns/{run_id}.json.
 - Update 	est_path_traversal_fix.py to test ConcurrentStorage directly and use real file paths for end-to-end validation.
 - Unskip 	est_run_saved_on_end and assert actual run file persistence.
 - Fix debug_tool.py to use load_run_sync() instead of the async load_run().

* fix(core): address round 2 of CodeRabbitAI reviews

 - Add _validate_key to _save_run_sync and _load_summary_sync to enforce path traversal protections on the lowest level APIs.
 - Invalidate summary cache and refresh run cache in save_run_sync() to match the async save_run() cache coherence behavior.
 - Add tests for load_summary and save_run_sync path traversal rejection.
2026-03-29 22:48:12 +08:00
Sundaram Kumar Jha c56440340a Merge origin/main into fix/quickstart-credentials-in-windows 2026-03-29 08:44:26 +05:30
Bhuvaneswari N c889ffd85d feat(scripts): add support for more LLM providers in check_llm_key.py (#6833)
* feat(scripts): add support for more LLM providers in check_llm_key.py

* fix(scripts): correct perplexity endpoint to /v1/models and simplify lambda kwargs to **_
2026-03-29 09:11:25 +08:00
Md. Afzal Hassan Ehsani 905a4f3516 feat(quickstart): add Local (Ollama) LLM provider option (#6028)
* feat(quickstart): add Local (Ollama) LLM provider option
- Detect Ollama via 'ollama list' in quickstart.sh and quickstart.ps1
- Add 'Local (Ollama)' menu option with interactive model picker
- Save provider=ollama, model=<selected> to ~/.hive/configuration.json
- Omit api_key_env_var for Ollama (no API key required)
Refs #5154, #5231

* feat: add local Ollama support and resolve native tool calling

This integrates Ollama as a first-class local provider choice during quickstart, and patches several configuration barriers preventing local models from safely executing the framework's agent graphs.

* **Quickstart Integration**: Added `Local (Ollama)` to the provider menu in both quickstart.sh and quickstart.ps1. When selected, it automatically queries `ollama list` and allows the user to pick an installed model without prompting for an API key.
* **Routing & Configuration**: Automatically sets `"api_base": "http://localhost:11434"` so LiteLLM routes correctly to the local daemon, and increases the default max_tokens config.py allocation to `32768`.
* **Native Tool Calling**: Normalized Ollama models to strictly use the ollama_chat provider prefix inside litellm.py and registered them as `supports_function_calling: True`. This forces native structured function calling and fixes the infinite loop caused by JSON-mode text fallbacks.
* **Context Truncation Fix**: Updated config.py to explicitly pass `"num_ctx": 16384` to Ollama. This prevents the local daemon from silently truncating the Queen agent's ~9,500 token system prompt (Ollama defaults to 2048 `num_ctx`).
* **UX Warnings**: Added terminal notices warning users to select high-parameter models (e.g., `qwen2.5:72b+`) to ensure sufficient contextual reasoning abilities.

Resolves #6027
Resolves #6028

* test: add unit tests for Ollama helper functions

Cover _is_ollama_model(), _ensure_ollama_chat_prefix(), and num_ctx
injection in get_llm_extra_kwargs() as requested in PR review.
Fix existing test_init_ollama_no_key_needed assertion to expect the
normalised ollama_chat/ prefix.

Made-with: Cursor

* chores: fixed merge conflict

* fix(ollama): address PR review comments and normalize provider config

* fix(ollama): align quickstart defaults and add tool_choice comment

* fix(ollama): enforce OLLAMA_DETECTED logic and resolve quickstart script syntax errors

* fix(ollama): align quickstart logic and cleanup test imports
2026-03-29 08:51:47 +08:00
Sundaram Kumar Jha 941605720f fix: add missing antigravity subscription option 2026-03-28 23:46:01 +05:30
Sundaram Kumar Jha 72e5c5c1c6 test: cover shell config fallbacks 2026-03-28 23:38:02 +05:30
Sundaram Kumar Jha 0f42c8c8c1 fix: align Git Bash shell config handling 2026-03-28 23:37:53 +05:30
RichardTang-Aden c3c3075610 Merge pull request #6811 from Hundao/fix/lazy-import-resend
fix: lazy import resend in email_tool
2026-03-27 14:37:41 -07:00
saurabhiiitm062 e9c1731c0f fix: address review comments + all tests passing 2026-03-28 00:58:28 +05:30
SAURABH KUMAR 0e2333daaf Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:47:29 +05:30
SAURABH KUMAR 5167c29aed Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:47:11 +05:30
SAURABH KUMAR 4da4d3b2c0 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:46:51 +05:30
SAURABH KUMAR 3e622af484 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:46:35 +05:30
SAURABH KUMAR 6600ce0ef9 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:46:21 +05:30
SAURABH KUMAR 74d5dd03dd Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:46:05 +05:30
SAURABH KUMAR d18091bb2c Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:45:50 +05:30
SAURABH KUMAR d1a1f36d6e Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:45:35 +05:30
SAURABH KUMAR 051b0fcef2 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:45:22 +05:30
SAURABH KUMAR e270d3210d Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:45:07 +05:30
SAURABH KUMAR d4a66d4b5f Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:44:51 +05:30
SAURABH KUMAR ad39b6ea50 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:44:29 +05:30
SAURABH KUMAR 71baf6166d Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:44:13 +05:30
SAURABH KUMAR 25afdae093 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:43:57 +05:30
SAURABH KUMAR 21700eb2ec Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:43:42 +05:30
SAURABH KUMAR 617462df52 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:43:23 +05:30
SAURABH KUMAR b3c1f1436b Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:42:46 +05:30
SAURABH KUMAR 310b922ce8 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:42:30 +05:30
SAURABH KUMAR 20b6553b07 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:42:09 +05:30
SAURABH KUMAR 1035cc9481 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:41:52 +05:30
SAURABH KUMAR 5d6dd1caa6 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:41:32 +05:30
SAURABH KUMAR 45ba771650 Apply suggestion from @levxn
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:41:15 +05:30
SAURABH KUMAR a4b15c0320 Add health check endpoint for Cloudflare API 2026-03-27 22:35:09 +05:30
SAURABH KUMAR 211619120e Update tools/tests/tools/test_cloudflare.py
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:26:50 +05:30
SAURABH KUMAR a78bb16e4b Update tools/tests/tools/test_cloudflare.py
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:26:00 +05:30
SAURABH KUMAR c93bcee933 Update tools/tests/tools/test_cloudflare.py
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:25:48 +05:30
SAURABH KUMAR 08160a004a Update tools/tests/tools/test_cloudflare.py
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:25:33 +05:30
SAURABH KUMAR ccd5de7496 Update tools/tests/tools/test_cloudflare.py
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:25:10 +05:30
SAURABH KUMAR c332ef8823 Update tools/tests/tools/test_cloudflare.py
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:24:58 +05:30
SAURABH KUMAR 06db11eebf Update tools/tests/tools/test_cloudflare.py
Co-authored-by: Levin <105410870+levxn@users.noreply.github.com>
2026-03-27 22:24:43 +05:30
Bryan @ Aden 86ef6fd8c5 Merge pull request #6822 from sundaram2021/fix/date-formatting-issue-on-windows
micro-fix: fix date formatting issue on windows and mattermost formatting issue
2026-03-27 07:24:26 -07:00
Sundaram Kumar Jha 95bdf4fe32 fix: mattermost formatting issue 2026-03-27 09:55:28 +05:30
Sundaram Kumar Jha 890d303d26 test: cover queen memory date formatting on Windows 2026-03-27 09:46:42 +05:30
Sundaram Kumar Jha 7fe60991e1 fix: use cross-platform queen memory date formatting 2026-03-27 09:46:27 +05:30
RichardTang-Aden a72938a163 Merge pull request #6747 from wakqasahmed/feat/mattermost-integration
feat(tools): add Mattermost messaging platform integration
2026-03-26 15:27:00 -07:00
Richard Tang 326a3dd1b7 docs: add honeycomb in readme 2026-03-26 14:55:00 -07:00
Richard Tang 183c6e2620 docs: readme with harness 2026-03-26 14:50:55 -07:00
Timothy @aden 1b40bff7da Merge pull request #6803 from aden-hive/fix/queen-cannot-read-skills
fix: allow curl in run_command and fix queen custom skill discovery
2026-03-26 12:56:03 -07:00
Timothy @aden 38b79edaee Merge pull request #6633 from sundaram2021/refactor/event-loop-node-modularization
refactor: modularize event loop node class methods and helpers
2026-03-26 12:47:53 -07:00
Sundaram Kumar Jha eb4f180192 chore: pull latest change 2026-03-27 00:48:01 +05:30
Sundaram Kumar Jha bf0b9a1edb refactor: cleanup compact llm function 2026-03-27 00:45:42 +05:30
Sundaram Kumar Jha 9667dd25cb chore: pull latest changes 2026-03-26 21:54:56 +05:30
hundao 33e4e8d440 fix: lazy import resend in email_tool to prevent tool registration crash
Fixes #4816
2026-03-26 18:43:04 +08:00
Shiva Santosh Reddy Aenugu c5ac29c81d fix(frontend): add 404 fallback route for unknown paths (#6373) 2026-03-26 18:24:01 +08:00
vakrahul 13c072d731 fix: match expected error message text in mcp_client and mcp_registry 2026-03-26 15:39:17 +05:30
Aaryann Chandola 5e31975cc3 feat(mcp-cli): add CLI management commands (#6350) (#6787)
* feat(mcp-cli): add hive mcp CLI management commands (#6350)

Implement the hive mcp subcommand group with shared helpers and all
P0/P1 management commands: install, add, remove, enable, disable,
list, info, config, search, health, update.

Includes update bridge (remove+reinstall with rollback on failure),
first-use security notice, credential prompting, secret masking,
and agent usage detection via load_agent_selection().

* test(mcp-cli): add CLI integration and handler tests (#6350)

58 tests covering all commands end-to-end:
- Real framework.cli.main() entrypoint dispatch (list, install, update)
- Real registry-on-disk integration (install, list, config, info, remove)
- All 11 command handlers (install, add, remove, enable, disable, list,
  info, config, search, health, update)
- Security notice shown only once
- Credential prompting stores overrides, skips when env set, handles cancel
- Secret masking in human output, JSON output, and config display
- Index refresh semantics (stale cache fallback vs no-cache hard fail)
- Update rollback on reinstall failure preserves original entry
- Update rejects local servers and pinned servers with correct remediation
- Bulk update skips local and pinned servers
- Argparse registration validates all 11 subcommands present
- _find_agents_using_server resolves via real load_agent_selection
- _parse_key_value_pairs validates KEY=VAL format

* fix(mcp-cli): mask list --json secrets, preserve enabled state on update, defer security sentinel (#6350)

- list --json now masks override values as <set> before emitting
- update preserves enabled=False state across reinstall
- security notice sentinel only written after successful install

* refactor(mcp-cli): fix docstring, share registry instance in update, extract _mask_overrides helper (#6350)

- Fix module docstring to reflect update's full behavior
- Pass registry instance to _cmd_mcp_update_server to avoid redundant disk I/O
- Extract _mask_overrides() used by list --json, info --json, info human, and config display
- Add comment about _find_agents_using_server path arithmetic limitation
2026-03-26 18:01:28 +08:00
vakrahul 82af76e72a feat: wire structured MCP errors into mcp_registry.py (closes #6352) 2026-03-26 15:30:10 +05:30
Amogh Raj a483f8d06a docs: add Windows quickstart.ps1 command in Quick Start section (#6781)
* docs: add Windows quickstart.ps1 command in Quick Start section

* fix: restore closing code fence and comment out Windows command

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-03-26 17:27:31 +08:00
saurabhiiitm062 859db7f056 fix: address review comments for cloudflare tool
- removed unreachable code
- updated firewall rule handling (rulesets API)
- added validation + error handling
- added missing test coverage
- fixed misleading documentation
2026-03-26 13:51:02 +05:30
saurabhiiitm062 6e0b5c7250 merge upstream main into cloudflare branch 2026-03-26 10:50:59 +05:30
Sundaram Kumar Jha e188c26e9f chore: revert changes 2026-03-26 08:11:39 +05:30
Fernando Mano 22d9fba1fd Feature: #6351 - Agent selection, tool resolution & framework integration -- MCP Registry integration deleted local test code -- fix failing tests 2026-03-24 22:56:56 -03:00
Fernando Mano c7d0afc775 Feature: #6351 - Agent selection, tool resolution & framework integration
Made-with: Cursor
2026-03-24 22:34:52 -03:00
Fernando Mano 45aafbc52b Merge branch 'main' into feat/agent-selection-tool-resolution-n-framework-integration 2026-03-24 17:02:08 -03:00
Levin 567340c05d Merge branch 'aden-hive:main' into skills/cli-commands 2026-03-24 22:58:03 +05:30
levxn 8d8656193d bug fix 2026-03-24 22:09:54 +05:30
levxn ef317371ce hive skill test implemented, --json flag for machine parsable outputs, fixed lints 2026-03-24 21:51:14 +05:30
Levin d5596ccb0a Merge branch 'aden-hive:main' into skills/cli-commands 2026-03-24 21:47:35 +05:30
levxn 5f1530ec5b minor bug fix, and lint issue fixes 2026-03-24 15:52:10 +05:30
Levin 8af32b421c Merge branch 'aden-hive:main' into skills/cli-commands 2026-03-24 13:53:40 +05:30
levxn 95cc8a4513 cli commands, v1 2026-03-24 02:23:20 +05:30
Sundaram Kumar Jha d648f3d315 refactor(event-loop): slim event loop node orchestration 2026-03-24 01:00:08 +05:30
Sundaram Kumar Jha b43044cf4d refactor(event-loop): untangle modular event loop imports 2026-03-24 00:59:55 +05:30
Sundaram Kumar Jha 4724320946 refactor(event-loop): add shared event loop types 2026-03-24 00:59:35 +05:30
Waqas Ahmed 89ab2e0a74 feat(tools): add Mattermost messaging platform integration
Add Mattermost as a new messaging tool following the existing Discord/Telegram
pattern. Supports self-hosted and cloud instances via personal access tokens.

Tools: list_teams, list_channels, get_channel, send_message, get_posts,
create_reaction, delete_post. Includes rate limit retry logic, credential
store + env var fallback, and comprehensive tests (41 unit + 50 conformance).

Closes #6746

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:07:30 +02:00
Sundaram Kumar Jha ee4682c565 chore: pull latest changes ; fix: merge conflict 2026-03-22 08:52:17 +05:30
SRI LIKHITA ADRU 1e2e6e03dd Merge branch 'main' into fix/stream-transient-retry-cap 2026-03-20 09:08:14 -04:00
saurabhiiitm062 becbdb3706 feat: cloudflare DNS/Zone tool integrations 2026-03-20 11:11:10 +05:30
Sundaram Kumar Jha 9b59255770 chore: pull latest change , refactor: modularize latest change 2026-03-20 11:05:12 +05:30
Sundaram Kumar Jha 49fd443da8 chore: resolve merge conflict 2026-03-20 10:01:07 +05:30
Fernando Mano b599a760e8 Feature: #6351 - Agent selection, tool resolution & framework integration -- first version with mocked MCPRegistry 2026-03-19 10:48:37 -03:00
Levin b4a37cdb03 Merge branch 'aden-hive:main' into skills/ds-ovrride-heuristics 2026-03-19 18:51:29 +05:30
Sundaram Kumar Jha 4885db318e fix: merge conflict 2026-03-19 09:22:44 +05:30
Sundaram Kumar Jha fa7ce53fb3 style(repo): fix ruff format violations
Apply Ruff formatting to the extracted event loop modules, the EventLoopNode wrappers, and the OpenRouter key check script so the lint CI format check passes cleanly.
2026-03-19 09:20:18 +05:30
Sundaram Kumar Jha 75a2ef2c4a Merge branch 'main' into refactor/event-loop-node-modularization 2026-03-19 09:14:10 +05:30
Sundaram Kumar Jha a0b9d6afaf chore: refresh locks 2026-03-19 09:08:10 +05:30
Sundaram Kumar Jha 74c0a85e3f refactor(graph): modularize event loop helpers
Extract EventLoopNode helper logic into focused event_loop modules while keeping the node responsible for orchestration.

Preserve the existing behavior and compatibility for compaction, event publishing, cursor persistence, synthetic tools, judge evaluation, stall detection, tool result handling, and subagent escalation wiring.
2026-03-19 09:07:19 +05:30
levxn 96609386a3 lints fixed 2026-03-18 22:18:16 +05:30
levxn 0cef0e6990 DS-12, DS-13 skill config overrides and runtime heuristics 2026-03-18 22:13:09 +05:30
vakrahul 2f15a16159 feat: structured MCP error codes and failure diagnostics (closes #6352) 2026-03-16 22:50:14 +05:30
Kartik d433cda209 fix: use CredentialStoreAdapter in sap_tool instead of raw os.getenv()
Made-with: Cursor
2026-03-13 22:30:50 +05:30
Sri-Likhita-adru 8b99bb8590 fix(llm): cap transient stream error retries at STREAM_TRANSIENT_MAX_RETRIES=3 2026-03-12 15:37:49 -04:00
816 changed files with 98893 additions and 58188 deletions
+70
View File
@@ -1,4 +1,74 @@
{
"permissions": {
"allow": [
"Bash(grep -n \"_is_context_too_large_error\" core/framework/agent_loop/agent_loop.py core/framework/agent_loop/internals/*.py)",
"Read(//^class/ {cls=$3} /def test_/**)",
"Read(//^ @pytest.mark.asyncio/{getline n; print NR\": \"n} /^ def test_/**)",
"Bash(python3)",
"Bash(grep -nE 'Tool\\\\\\(\\\\s*$|name=\"[a-z_]+\",' core/framework/tools/queen_lifecycle_tools.py)",
"Bash(awk -F'\"' '{print $2}')",
"Bash(grep -n \"create_colony\\\\|colony-spawn\\\\|colony_spawn\" /home/timothy/aden/hive/core/framework/agents/queen/nodes/__init__.py /home/timothy/aden/hive/core/framework/tools/*.py)",
"Bash(git stash:*)",
"Bash(python3 -c \"import sys,json; d=json.loads\\(sys.stdin.read\\(\\)\\); print\\('keys:', list\\(d.keys\\(\\)\\)[:10]\\)\")",
"Bash(python3 -c ':*)",
"Bash(uv run:*)",
"Read(//tmp/**)",
"Bash(grep -n \"useColony\\\\|const { queens, queenProfiles\" /home/timothy/aden/hive/core/frontend/src/pages/queen-dm.tsx)",
"Bash(awk 'NR==385,/\\\\}, \\\\[/' /home/timothy/aden/hive/core/frontend/src/pages/queen-dm.tsx)",
"Bash(xargs -I{} sh -c 'if ! grep -q \"^import base64\\\\|^from base64\" \"{}\"; then echo \"MISSING: {}\"; fi')",
"Bash(find /home/timothy/aden/hive/core/framework -name \"*.py\" -type f -exec grep -l \"FileConversationStore\\\\|class.*ConversationStore\" {} \\\\;)",
"Bash(find /home/timothy/aden/hive/core/framework -name \"*.py\" -exec grep -l \"run_parallel_workers\\\\|create_colony\" {} \\\\;)",
"Bash(awk '/^ async def execute\\\\\\(self, ctx: AgentContext\\\\\\)/,/^ async def [a-z_]+/ {print NR\": \"$0}' /home/timothy/aden/hive/core/framework/agent_loop/agent_loop.py)",
"Bash(grep -r \"max_concurrent_workers\\\\|max_depth\\\\|recursion\\\\|spawn.*bomb\" /home/timothy/aden/hive/core/framework/host/*.py)",
"Bash(wc -l /home/timothy/aden/hive/tools/src/gcu/browser/*.py /home/timothy/aden/hive/tools/src/gcu/browser/tools/*.py)",
"Bash(file /tmp/gcu_verify/*.png)",
"Bash(ps -eo pid,cmd)",
"Bash(ps -o pid,lstart,cmd -p 746640)",
"Bash(kill 746636)",
"Bash(ps -eo pid,lstart,cmd)",
"Bash(grep -E \"^d|\\\\.py$\")",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
"Bash(xargs cat:*)",
"Bash(find /home/timothy/aden/hive -path \"*/.venv\" -prune -o -name \"*.py\" -type f -exec grep -l \"frontend\\\\|UI\\\\|terminal\\\\|interactive\\\\|TUI\" {} \\\\;)",
"Bash(wc -l /home/timothy/.hive/backup/*/SKILL.md)",
"Bash(awk -F'::' '{print $1}')",
"Bash(wait)",
"Bash(pkill -f \"pytest.*test_event_loop_node\")",
"Bash(pkill -f \"pytest.*TestToolConcurrency\")",
"Bash(grep -n \"def.*discover\\\\|/api/agents\\\\|agents_discover\" /home/timothy/aden/hive/core/framework/server/*.py)",
"Bash(bun run:*)",
"Bash(npx eslint:*)",
"Bash(npm run:*)",
"Bash(npm test:*)",
"Bash(grep -n \"PIL\\\\|Image\\\\|to_thread\\\\|run_in_executor\" /home/timothy/aden/hive/tools/src/gcu/browser/*.py /home/timothy/aden/hive/tools/src/gcu/browser/tools/*.py)",
"WebFetch(domain:docs.litellm.ai)",
"Bash(cat /home/timothy/aden/hive/.venv/lib/python3.11/site-packages/litellm-*.dist-info/METADATA)",
"Bash(find \"/home/timothy/.hive/agents/queens/queen_brand_design/sessions/session_20260415_100751_d49f4c28/\" -type f -name \"*.json*\" -exec grep -l \"协日\" {} \\\\;)",
"Bash(grep -v ':0$')",
"Bash(curl -s -m 2 http://127.0.0.1:4002/sse -o /dev/null -w 'status=%{http_code} time=%{time_total}s\\\\n')",
"mcp__gcu-tools__browser_status",
"mcp__gcu-tools__browser_start",
"mcp__gcu-tools__browser_navigate",
"mcp__gcu-tools__browser_evaluate",
"mcp__gcu-tools__browser_screenshot",
"mcp__gcu-tools__browser_open",
"mcp__gcu-tools__browser_click_coordinate",
"mcp__gcu-tools__browser_get_rect",
"mcp__gcu-tools__browser_type_focused",
"mcp__gcu-tools__browser_wait",
"Bash(python3 -c ' *)",
"Bash(python3 scripts/debug_queen_prompt.py independent)",
"Bash(curl -s --max-time 2 http://127.0.0.1:9230/status)",
"Bash(python3 -c \"import json, sys; print\\(json.loads\\(sys.stdin.read\\(\\)\\)['data']['content']\\)\")",
"Bash(python3 -c \"import json; json.load\\(open\\('/home/timothy/aden/hive/tools/browser-extension/manifest.json'\\)\\)\")"
],
"additionalDirectories": [
"/home/timothy/.hive/skills/writing-hive-skills",
"/tmp",
"/home/timothy/.hive/skills",
"/home/timothy/aden/hive/core/frontend/src/components"
]
},
"hooks": {
"PostToolUse": [
{
+241
View File
@@ -0,0 +1,241 @@
---
name: browser-edge-cases
description: SOP for debugging browser automation failures on complex websites. Use when browser tools fail on specific sites like LinkedIn, Twitter/X, SPAs, or sites with Shadow DOM.
license: MIT
---
# Browser Tool Edge Cases
Standard Operating Procedure for debugging and fixing browser automation failures on complex websites.
## When to Use This Skill
- `browser_scroll` succeeds but page doesn't move
- `browser_click` succeeds but no action triggered
- `browser_type` text disappears or doesn't work
- `browser_snapshot` hangs or returns stale content
- `browser_navigate` loads wrong content
## SOP: Debugging Browser Tool Failures
### Phase 1: Reproduce & Isolate
```
1. Create minimal test case demonstrating failure
2. Test against simple site (example.com) to verify tool works
3. Test against problematic site to confirm issue
```
**Quick isolation test:**
```python
# Test 1: Does the tool work at all?
await browser_navigate(tab_id, "https://example.com")
result = await browser_scroll(tab_id, "down", 100)
# Should work on simple sites
# Test 2: Does it fail on the problematic site?
await browser_navigate(tab_id, "https://linkedin.com/feed")
result = await browser_scroll(tab_id, "down", 100)
# If this fails but example.com works → site-specific edge case
```
### Phase 2: Analyze Root Cause
**Step 2a: Check console for errors**
```python
console = await browser_console(tab_id)
# Look for: CSP violations, React errors, JavaScript exceptions
```
**Step 2b: Inspect DOM structure**
```python
html = await browser_html(tab_id)
snapshot = await browser_snapshot(tab_id)
# Look for:
# - Nested scrollable divs (overflow: scroll/auto)
# - Shadow DOM roots
# - iframes
# - Custom widgets
```
**Step 2c: Identify the pattern**
| Symptom | Likely Cause | Check |
|---------|--------------|-------|
| Scroll doesn't move | Nested scroll container | Look for `overflow: scroll` divs |
| Click no effect | Element covered | Check `getBoundingClientRect` vs viewport |
| Type clears | Autocomplete/React | Check for event listeners on input; try `browser_type_focused` |
| Snapshot hangs | Huge DOM | Check node count in snapshot |
| Snapshot stale | SPA hydration | Wait after navigation |
### Phase 3: Implement Multi-Layer Fix
**Pattern: Always have fallbacks**
```python
async def robust_operation(tab_id):
# Method 1: Primary approach
try:
result = await primary_method(tab_id)
if verify_success(result):
return result
except Exception:
pass
# Method 2: CDP fallback
try:
result = await cdp_fallback(tab_id)
if verify_success(result):
return result
except Exception:
pass
# Method 3: JavaScript fallback
return await javascript_fallback(tab_id)
```
**Pattern: Always add timeouts**
```python
# Bad - can hang forever
result = await browser_snapshot(tab_id)
# Good - fails fast with useful error
try:
result = await browser_snapshot(tab_id, timeout_s=10.0)
except asyncio.TimeoutError:
# Handle timeout gracefully
result = await fallback_snapshot(tab_id)
```
### Phase 4: Verify Fix
```
1. Run against problematic site → should work
2. Run against simple site → should still work (regression check)
3. Document in registry.md
```
## Pattern Library
### P1: Nested Scrollable Containers
**Sites:** LinkedIn, Twitter/X, any SPA with scrollable feeds
**Detection:**
```javascript
// Find largest scrollable container
const candidates = [];
document.querySelectorAll('*').forEach(el => {
const style = getComputedStyle(el);
if (style.overflow.includes('scroll') || style.overflow.includes('auto')) {
const rect = el.getBoundingClientRect();
if (rect.width > 100 && rect.height > 100) {
candidates.push({el, area: rect.width * rect.height});
}
}
});
candidates.sort((a, b) => b.area - a.area);
return candidates[0]?.el;
```
**Fix:** Dispatch scroll events at container's center, not viewport center.
### P2: Element Covered by Overlay
**Sites:** Modals, tooltips, SPAs with loading overlays
**Detection:**
```javascript
const rect = element.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const topElement = document.elementFromPoint(centerX, centerY);
return topElement === element || element.contains(topElement);
```
**Fix:** Wait for overlay to disappear, or use JavaScript click.
### P3: React Synthetic Events
**Sites:** React SPAs, modern web apps
**Detection:** If CDP click doesn't trigger handler but manual click works.
**Fix:** Use JavaScript click as primary:
```javascript
element.click();
```
### P4: Huge DOM / Accessibility Tree
**Sites:** LinkedIn, Facebook, Twitter (feeds with 1000s of nodes)
**Detection:**
```javascript
document.querySelectorAll('*').length > 5000
```
**Fix:**
1. Add timeout to snapshot operation
2. Truncate tree at 2000 nodes
3. Fall back to DOM-based snapshot if accessibility tree too large
### P5: SPA Hydration Delay
**Sites:** React, Vue, Angular SPAs after navigation
**Detection:**
```javascript
// Check if React app has hydrated
document.querySelector('[data-reactroot]') ||
document.querySelector('[data-reactid]')
```
**Fix:** Wait for specific selector after navigation:
```python
await browser_navigate(tab_id, url, wait_until="load")
await browser_wait(tab_id, selector='[data-testid="content"]', timeout_ms=5000)
```
### P6: Shadow DOM
**Sites:** Components using Shadow DOM, Lit elements
**Detection:**
```javascript
document.querySelectorAll('*').some(el => el.shadowRoot)
```
**Fix:** Pierce shadow root:
```javascript
function queryShadow(selector) {
const parts = selector.split('>>>');
let node = document;
for (const part of parts) {
if (node.shadowRoot) {
node = node.shadowRoot.querySelector(part.trim());
} else {
node = node.querySelector(part.trim());
}
}
return node;
}
```
## Quick Reference
| Issue | Primary Fix | Fallback |
|-------|-------------|----------|
| Scroll not working | Find scrollable container | Mouse wheel at container center |
| Click no effect | JavaScript click() | CDP mouse events |
| Type clears | Add delay_ms | Use `browser_type_focused` (Input.insertText) |
| Snapshot hangs | Add timeout_s | DOM snapshot fallback |
| Stale content | Wait for selector | Increase wait_until timeout |
| Shadow DOM | Pierce selector | JavaScript traversal |
## References
- [registry.md](registry.md) - Full list of known edge cases
- [scripts/test_case.py](scripts/test_case.py) - Template for testing new cases
- [BROWSER_USE_PATTERNS.md](../../tools/BROWSER_USE_PATTERNS.md) - Implementation patterns from browser-use
@@ -0,0 +1,261 @@
# Browser Edge Case Registry
Curated list of known browser automation edge cases with symptoms, causes, and fixes.
---
## Scroll Issues
### #1: LinkedIn Nested Scroll Container
| Attribute | Value |
|-----------|-------|
| **Site** | LinkedIn (linkedin.com/feed) |
| **Symptom** | `browser_scroll()` returns `{ok: true}` but page doesn't move |
| **Root Cause** | Content is in a nested scrollable div (`overflow: scroll`), not the main window |
| **Detection** | `document.querySelectorAll('*')` with `overflow: scroll/auto` has large candidates |
| **Fix** | JavaScript finds largest scrollable container, uses `container.scrollBy()` |
| **Code** | `bridge.py:808-891` - smart scroll with container detection |
| **Verified** | 2026-04-03 ✓ |
### #2: Twitter/X Lazy Loading
| Attribute | Value |
|-----------|-------|
| **Site** | Twitter/X (x.com) |
| **Symptom** | Infinite scroll doesn't load new content |
| **Root Cause** | Lazy loading requires content to be visible before loading more |
| **Detection** | Scroll position at bottom but no new `[data-testid="tweet"]` elements |
| **Fix** | Add `wait_for_selector` between scroll calls with 1s delay |
| **Code** | Test file: `tests/test_x_page_load_repro.py` |
| **Verified** | - |
### #3: Modal/Dialog Scroll Container
| Attribute | Value |
|-----------|-------|
| **Site** | Any site with modal dialogs |
| **Symptom** | Scroll scrolls background page, not modal content |
| **Root Cause** | Modal has its own scroll container with `overflow: scroll` |
| **Detection** | Visible element with `position: fixed` and scrollable content |
| **Fix** | Find visible modal container (highest z-index scrollable), scroll that |
| **Code** | - |
| **Verified** | - |
---
## Click Issues
### #4: Element Covered by Overlay
| Attribute | Value |
|-----------|-------|
| **Site** | SPAs, sites with loading overlays |
| **Symptom** | Click succeeds but no action triggered |
| **Root Cause** | Element is covered by transparent overlay, tooltip, or iframe |
| **Detection** | `document.elementFromPoint(x, y) !== target` |
| **Fix** | Wait for overlay to disappear, or use JavaScript `element.click()` |
| **Code** | `bridge.py:394-591` - JavaScript click as primary |
| **Verified** | - |
### #5: React Synthetic Events
| Attribute | Value |
|-----------|-------|
| **Site** | React applications |
| **Symptom** | CDP click doesn't trigger React handler |
| **Root Cause** | React uses synthetic events that don't respond to CDP events |
| **Detection** | Site uses React (check for `__reactFiber$` or `data-reactroot`) |
| **Fix** | Use JavaScript `element.click()` as primary method |
| **Code** | `bridge.py:394-591` - JavaScript-first click |
| **Verified** | - |
### #6: Shadow DOM Elements
| Attribute | Value |
|-----------|-------|
| **Site** | Components using Shadow DOM, Lit elements |
| **Symptom** | `querySelector` can't find element |
| **Root Cause** | Element is inside a shadow root, not main DOM tree |
| **Detection** | `element.shadowRoot !== null` on parent elements |
| **Fix** | Use piercing selector (`host >>> target`) or traverse shadow roots |
| **Code** | See SKILL.md P6 pattern |
| **Verified** | 2026-04-03 ✓ |
---
## Input Issues
### #7: ContentEditable / Rich Text Editors
| Attribute | Value |
|-----------|-------|
| **Site** | Rich text editors (Notion, Slack web, etc.) |
| **Symptom** | `browser_type()` doesn't insert text |
| **Root Cause** | Element is `contenteditable`, not an `<input>` or `<textarea>` |
| **Detection** | `element.contentEditable === 'true'` |
| **Fix** | Focus via JavaScript, use `execCommand('insertText')` or `Input.dispatchKeyEvent` |
| **Code** | `bridge.py:616-694` - contentEditable handling |
| **Verified** | 2026-04-03 ✓ |
### #8: Autocomplete Field Clearing
| Attribute | Value |
|-----------|-------|
| **Site** | Search fields with autocomplete, address forms |
| **Symptom** | Typed text gets cleared immediately |
| **Root Cause** | Field expects realistic keystroke timing for autocomplete |
| **Detection** | Field has autocomplete listeners or dropdown appears |
| **Fix** | Add `delay_ms=50` between keystrokes |
| **Code** | `bridge.py:type()` - delay_ms parameter |
| **Verified** | 2026-04-03 ✓ |
### #9: Custom Date Pickers
| Attribute | Value |
|-----------|-------|
| **Site** | Forms with custom date widgets |
| **Symptom** | Can't type date into date field |
| **Root Cause** | Custom widget intercepts and blocks keyboard input |
| **Detection** | Typing doesn't change field value |
| **Fix** | Click calendar widget icon, select date from dropdown |
| **Code** | - |
| **Verified** | - |
---
## Snapshot Issues
### #10: LinkedIn Huge DOM Tree
| Attribute | Value |
|-----------|-------|
| **Site** | LinkedIn, Facebook, Twitter feeds |
| **Symptom** | `browser_snapshot()` hangs forever |
| **Root Cause** | 10k+ DOM nodes, accessibility tree has 50k+ nodes |
| **Detection** | `document.querySelectorAll('*').length > 5000` |
| **Fix** | Add `timeout_s` param with `asyncio.timeout()`, proper error handling |
| **Code** | `bridge.py:1041-1028` - snapshot with timeout protection |
| **Verified** | 2026-04-03 ✓ (0.08s on LinkedIn) |
### #11: SPA Hydration Delay
| Attribute | Value |
|-----------|-------|
| **Site** | React/Vue/Angular SPAs |
| **Symptom** | Snapshot shows old content after navigation |
| **Root Cause** | Client-side hydration hasn't completed when snapshot runs |
| **Detection** | `document.readyState === 'complete'` but content missing |
| **Fix** | Wait for specific selector after navigation |
| **Code** | Test file: `tests/test_x_page_load_repro.py` |
| **Verified** | - |
### #12: iframe Content Missing
| Attribute | Value |
|-----------|-------|
| **Site** | Sites with embedded content |
| **Symptom** | Snapshot missing iframe content |
| **Root Cause** | Accessibility tree doesn't include iframe content |
| **Detection** | `document.querySelectorAll('iframe')` has results |
| **Fix** | Use `DOM.getFrameOwner` + separate snapshot for each iframe |
| **Code** | - |
| **Verified** | - |
---
## Navigation Issues
### #13: SPA Navigation Events
| Attribute | Value |
|-----------|-------|
| **Site** | React Router, Vue Router SPAs |
| **Symptom** | `wait_until="load"` fires before content ready |
| **Root Cause** | SPA uses client-side routing, no full page load |
| **Detection** | URL changes but `load` event already fired |
| **Fix** | Use `wait_until="networkidle"` or `wait_for_selector` |
| **Code** | `bridge.py:navigate()` - wait_until options |
| **Verified** | - |
### #14: Cross-Origin Redirects
| Attribute | Value |
|-----------|-------|
| **Site** | OAuth flows, SSO logins |
| **Symptom** | Navigation fails during redirect |
| **Root Cause** | Cross-origin security prevents CDP tracking |
| **Detection** | URL changes to different domain |
| **Fix** | Use `wait_for_url` with pattern matching instead of exact URL |
| **Code** | - |
| **Verified** | - |
---
## Screenshot Issues
### #15: Selector Screenshot Not Implemented
| Attribute | Value |
|-----------|-------|
| **Site** | Any site |
| **Symptom** | `browser_screenshot(selector="h1")` takes full viewport instead of element |
| **Root Cause** | `selector` param existed in signature but was silently ignored in both `bridge.py` and `inspection.py` |
| **Detection** | Screenshot with selector same byte size as screenshot without selector |
| **Fix** | Use CDP `Runtime.evaluate` to call `getBoundingClientRect()` on the element, pass result as `clip` to `Page.captureScreenshot` |
| **Code** | `bridge.py:1315-1344` - selector clip logic; `inspection.py:94-96` - pass selector to bridge |
| **Verified** | 2026-04-03 ✓ (JS rect query returns correct viewport coords; requires server restart) |
### #16: Stale Browser Context (Group ID Mismatch)
| Attribute | Value |
|-----------|-------|
| **Site** | Any |
| **Symptom** | `browser_open()` returns `"No group with id: XXXXXXX"` even though `browser_status` shows `running: true` |
| **Root Cause** | In-memory `_contexts` dict has a stale `groupId` from a Chrome tab group that was closed outside the tool (e.g. user closed the tab group) |
| **Detection** | `browser_status` returns `running: true` but `browser_open` fails with "No group with id" |
| **Fix** | Call `browser_stop()` to clear stale context from `_contexts`, then `browser_start()` again |
| **Code** | `tools/lifecycle.py:144-160` - `already_running` check uses cached dict without validating against Chrome |
| **Verified** | 2026-04-03 ✓ |
---
## How to Add New Edge Cases
1. **Reproduce** the issue with minimal test case
2. **Document** using the template below
3. **Implement** fix with multi-layer fallback
4. **Verify** against both problematic and simple sites
5. **Submit** by appending to this file
### Template
```markdown
### #N: [Short Title]
| Attribute | Value |
|-----------|-------|
| **Site** | [URL or site type] |
| **Symptom** | [What the user observes] |
| **Root Cause** | [Technical explanation] |
| **Detection** | [JavaScript to detect this case] |
| **Fix** | [Solution approach] |
| **Code** | [File:line reference if implemented] |
| **Verified** | [Date or "pending"] |
```
---
## Statistics
| Category | Count |
|----------|-------|
| Scroll Issues | 3 |
| Click Issues | 3 |
| Input Issues | 3 |
| Snapshot Issues | 3 |
| Navigation Issues | 2 |
| Screenshot Issues | 2 |
| **Total** | **16** |
Last updated: 2026-04-03
@@ -0,0 +1,110 @@
#!/usr/bin/env python
"""
Test #2: Twitter/X Lazy Loading Scroll
Symptom: Infinite scroll doesn't load new content
Root Cause: Lazy loading requires content to be visible before loading more
Fix: Add wait_for_selector between scroll calls
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
BRIDGE_PORT = 9229
CONTEXT_NAME = "twitter-scroll-test"
async def test_twitter_lazy_scroll():
"""Test that repeated scrolls with waits load new content."""
print("=" * 70)
print("TEST #2: Twitter/X Lazy Loading Scroll")
print("=" * 70)
bridge = BeelineBridge()
try:
await bridge.start()
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
print(f"Waiting for extension... ({i + 1}/10)")
else:
print("✗ Extension not connected")
return
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
# Navigate to Twitter/X
print("\n--- Navigating to X.com ---")
await bridge.navigate(tab_id, "https://x.com", wait_until="networkidle", timeout_ms=30000)
print("✓ Page loaded")
# Wait for tweets to appear
print("\n--- Waiting for tweets ---")
await bridge.wait_for_selector(tab_id, '[data-testid="tweet"]', timeout_ms=10000)
# Count initial tweets
initial_count = await bridge.evaluate(
tab_id,
"(function() { return document.querySelectorAll('[data-testid=\"tweet\"]').length; })()",
)
print(f"Initial tweet count: {initial_count.get('result', 0)}")
# Take screenshot of initial state
screenshot = await bridge.screenshot(tab_id)
print(f"Screenshot: {len(screenshot.get('data', ''))} bytes")
# Scroll multiple times with waits
print("\n--- Scrolling with waits ---")
for i in range(3):
result = await bridge.scroll(tab_id, "down", 500)
print(f" Scroll {i + 1}: {result.get('method', 'unknown')} method")
# Wait for new content to load
await asyncio.sleep(2)
# Count tweets after scroll
count_result = await bridge.evaluate(
tab_id,
"(function() { return document.querySelectorAll('[data-testid=\"tweet\"]').length; })()",
)
count = count_result.get("result", 0)
print(f" Tweet count after scroll: {count}")
# Final count
final_count = await bridge.evaluate(
tab_id,
"(function() { return document.querySelectorAll('[data-testid=\"tweet\"]').length; })()",
)
final = final_count.get("result", 0)
initial = initial_count.get("result", 0)
print("\n--- Results ---")
print(f"Initial tweets: {initial}")
print(f"Final tweets: {final}")
if final > initial:
print(f"✓ PASS: Loaded {final - initial} new tweets")
else:
print("✗ FAIL: No new tweets loaded (may need login)")
await bridge.destroy_context(group_id)
print("\n✓ Context destroyed")
finally:
await bridge.stop()
if __name__ == "__main__":
asyncio.run(test_twitter_lazy_scroll())
@@ -0,0 +1,96 @@
#!/usr/bin/env python
"""
Test #3: Modal/Dialog Scroll Container
Symptom: Scroll scrolls background page, not modal content
Root Cause: Modal has its own scroll container with overflow: scroll
Fix: Find visible modal container (highest z-index scrollable), scroll that
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
BRIDGE_PORT = 9229
CONTEXT_NAME = "modal-scroll-test"
# Test site with modal - using a demo site
MODAL_DEMO_URL = "https://www.w3schools.com/howto/howto_css_modals.asp"
async def test_modal_scroll():
"""Test that scroll targets modal content, not background."""
print("=" * 70)
print("TEST #3: Modal/Dialog Scroll Container")
print("=" * 70)
bridge = BeelineBridge()
try:
await bridge.start()
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
else:
print("✗ Extension not connected")
return
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
# Navigate to modal demo
print("\n--- Navigating to modal demo ---")
await bridge.navigate(tab_id, MODAL_DEMO_URL, wait_until="load")
print("✓ Page loaded")
# Take screenshot before
screenshot_before = await bridge.screenshot(tab_id)
print(f"Screenshot before: {len(screenshot_before.get('data', ''))} bytes")
# Click button to open modal
print("\n--- Opening modal ---")
# Find and click the "Open Modal" button
result = await bridge.click(tab_id, ".ws-btn", timeout_ms=5000)
print(f"Click result: {result}")
await asyncio.sleep(1)
# Take screenshot with modal open
screenshot_modal = await bridge.screenshot(tab_id)
print(f"Screenshot modal open: {len(screenshot_modal.get('data', ''))} bytes")
# Try to scroll within modal
print("\n--- Scrolling modal content ---")
result = await bridge.scroll(tab_id, "down", 100)
print(f"Scroll result: {result}")
await asyncio.sleep(0.5)
# Take screenshot after scroll
screenshot_after = await bridge.screenshot(tab_id)
print(f"Screenshot after scroll: {len(screenshot_after.get('data', ''))} bytes")
# Check if modal content scrolled (not background)
# This is a visual check - we can verify by comparing screenshots
print("\n--- Results ---")
print(f"Modal scroll test completed. Method used: {result.get('method', 'unknown')}")
print("Visual verification needed: Check if modal content scrolled vs background")
await bridge.destroy_context(group_id)
print("\n✓ Context destroyed")
finally:
await bridge.stop()
if __name__ == "__main__":
asyncio.run(test_modal_scroll())
@@ -0,0 +1,123 @@
#!/usr/bin/env python
"""
Test #4: Element Covered by Overlay
Symptom: Click succeeds but no action triggered
Root Cause: Element is covered by transparent overlay, tooltip, or iframe
Detection: document.elementFromPoint(x, y) !== target
Fix: Wait for overlay to disappear, or use JavaScript element.click()
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
CONTEXT_NAME = "overlay-click-test"
async def test_overlay_click():
"""Test clicking elements that are covered by overlays."""
print("=" * 70)
print("TEST #4: Element Covered by Overlay")
print("=" * 70)
bridge = BeelineBridge()
try:
await bridge.start()
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
else:
print("✗ Extension not connected")
return
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
# Create a test page with overlay
print("\n--- Creating test page with overlay ---")
test_html = """
<!DOCTYPE html>
<html>
<head><title>Overlay Test</title></head>
<body>
<button id="target-btn" onclick="alert('Clicked!')">Click Me</button>
<div id="overlay" style="position:fixed;top:0;left:0;
width:100%;height:100%;
background:rgba(0,0,0,0.3);z-index:1000;"></div>
<script>
window.clickCount = 0;
document.getElementById('target-btn').addEventListener('click', () => {
window.clickCount++;
});
</script>
</body>
</html>
"""
# Navigate to data URL
import base64
data_url = f"data:text/html;base64,{base64.b64encode(test_html.encode()).decode()}"
await bridge.navigate(tab_id, data_url, wait_until="load")
# Screenshot before
screenshot = await bridge.screenshot(tab_id)
print(f"Screenshot: {len(screenshot.get('data', ''))} bytes")
# Try to click the covered button
print("\n--- Attempting to click covered button ---")
# First, check if element is covered
coverage_check = await bridge.evaluate(
tab_id,
"""
(function() {
const btn = document.getElementById('target-btn');
const rect = btn.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const topElement = document.elementFromPoint(centerX, centerY);
return {
isCovered: topElement !== btn && !btn.contains(topElement),
topElement: topElement?.tagName,
targetElement: btn.tagName
};
})();
""",
)
print(f"Coverage check: {coverage_check.get('result', {})}")
# Try CDP click (may fail due to overlay)
click_result = await bridge.click(tab_id, "#target-btn", timeout_ms=5000)
print(f"Click result: {click_result}")
# Check if click registered
count_result = await bridge.evaluate(tab_id, "(function() { return window.clickCount; })()")
count = count_result.get("result", 0)
print(f"Click count after CDP click: {count}")
if count > 0:
print("✓ PASS: JavaScript click penetrated overlay")
else:
print("✗ FAIL: Click did not reach button (overlay blocked it)")
await bridge.destroy_context(group_id)
print("\n✓ Context destroyed")
finally:
await bridge.stop()
if __name__ == "__main__":
asyncio.run(test_overlay_click())
@@ -0,0 +1,150 @@
#!/usr/bin/env python
"""
Test #6: Shadow DOM Elements
Symptom: querySelector can't find element
Root Cause: Element is inside a shadow root, not main DOM tree
Detection: element.shadowRoot !== null on parent elements
Fix: Use piercing selector (host >>> target) or traverse shadow roots
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
CONTEXT_NAME = "shadow-dom-test"
async def test_shadow_dom():
"""Test clicking elements inside Shadow DOM."""
print("=" * 70)
print("TEST #6: Shadow DOM Elements")
print("=" * 70)
bridge = BeelineBridge()
try:
await bridge.start()
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
else:
print("✗ Extension not connected")
return
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
# Create test page with Shadow DOM
print("\n--- Creating test page with Shadow DOM ---")
test_html = """
<!DOCTYPE html>
<html>
<head><title>Shadow DOM Test</title></head>
<body>
<div id="shadow-host"></div>
<script>
const host = document.getElementById('shadow-host');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button { padding: 10px 20px; font-size: 16px; }
</style>
<button id="shadow-btn">Shadow Button</button>
`;
shadow.getElementById('shadow-btn').addEventListener('click', () => {
window.shadowClickCount = (window.shadowClickCount || 0) + 1;
console.log('Shadow button clicked:', window.shadowClickCount);
});
</script>
</body>
</html>
"""
# Write to file and use file:// URL (data: URLs don't work well with extension)
test_file = Path("/tmp/shadow_dom_test.html")
test_file.write_text(test_html.strip())
file_url = f"file://{test_file}"
await bridge.navigate(tab_id, file_url, wait_until="load")
print("✓ Page loaded")
# Screenshot
screenshot = await bridge.screenshot(tab_id)
print(f"Screenshot: {len(screenshot.get('data', ''))} bytes")
# Detect Shadow DOM
print("\n--- Detecting Shadow DOM ---")
detection = await bridge.evaluate(
tab_id,
"""
(function() {
const hosts = [];
document.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) {
hosts.push({
tag: el.tagName,
id: el.id,
hasButton: el.shadowRoot.querySelector('button') !== null
});
}
});
return { count: hosts.length, hosts };
})();
""",
)
print(f"Shadow DOM detection: {detection.get('result', {})}")
# Try to click shadow button using regular selector (should fail)
print("\n--- Attempting click with regular selector ---")
try:
result = await bridge.click(tab_id, "#shadow-btn", timeout_ms=3000)
print(f"Result: {result}")
except Exception as e:
print(f"Expected failure: {e}")
# Try to click using JavaScript that pierces shadow DOM
print("\n--- Clicking via JavaScript shadow piercing ---")
click_result = await bridge.evaluate(
tab_id,
"""
(function() {
const host = document.getElementById('shadow-host');
const btn = host.shadowRoot.getElementById('shadow-btn');
if (btn) {
btn.click();
return { success: true, clicked: 'shadow-btn' };
}
return { success: false, error: 'Button not found' };
})();
""",
)
print(f"JS click result: {click_result.get('result', {})}")
# Verify click was registered
count_result = await bridge.evaluate(tab_id, "(function() { return window.shadowClickCount || 0; })()")
count = count_result.get("result") or 0
print(f"Shadow click count: {count}")
if count and count > 0:
print("✓ PASS: Shadow DOM element clicked successfully")
else:
print("✗ FAIL: Could not click Shadow DOM element")
await bridge.destroy_context(group_id)
print("\n✓ Context destroyed")
finally:
await bridge.stop()
if __name__ == "__main__":
asyncio.run(test_shadow_dom())
@@ -0,0 +1,180 @@
#!/usr/bin/env python
"""
Test #7: ContentEditable / Rich Text Editors
Symptom: browser_type() doesn't insert text
Root Cause: Element is contenteditable, not an <input> or <textarea>
Detection: element.contentEditable === 'true'
Fix: Focus via JavaScript, use execCommand('insertText') or Input.dispatchKeyEvent
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
CONTEXT_NAME = "contenteditable-test"
async def test_contenteditable():
"""Test typing into contenteditable elements."""
print("=" * 70)
print("TEST #7: ContentEditable / Rich Text Editors")
print("=" * 70)
bridge = BeelineBridge()
try:
await bridge.start()
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
else:
print("✗ Extension not connected")
return
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
# Create test page with contenteditable
test_html = """
<!DOCTYPE html>
<html>
<head><title>ContentEditable Test</title></head>
<body>
<h2>ContentEditable Test</h2>
<h3>1. Simple contenteditable div</h3>
<div id="editor1" contenteditable="true"
style="border:1px solid #ccc;padding:10px;
min-height:50px;">Start text</div>
<h3>2. Rich text editor (like Notion)</h3>
<div id="editor2" contenteditable="true"
style="border:1px solid #ccc;padding:10px;
min-height:50px;">
<p>Type here...</p>
</div>
<h3>3. Regular input (for comparison)</h3>
<input id="input1" type="text" placeholder="Regular input" />
<script>
// Track content changes
window.editor1Content = '';
window.editor2Content = '';
document.getElementById('editor1').addEventListener('input', (e) => {
window.editor1Content = e.target.innerText;
});
document.getElementById('editor2').addEventListener('input', (e) => {
window.editor2Content = e.target.innerText;
});
</script>
</body>
</html>
"""
# Write to file and use file:// URL (data: URLs don't work well with extension)
test_file = Path("/tmp/contenteditable_test.html")
test_file.write_text(test_html.strip())
file_url = f"file://{test_file}"
await bridge.navigate(tab_id, file_url, wait_until="load")
print("✓ Page loaded")
# Screenshot with timeout protection
try:
screenshot = await asyncio.wait_for(bridge.screenshot(tab_id), timeout=10.0)
print(f"Screenshot: {len(screenshot.get('data', ''))} bytes")
except asyncio.TimeoutError:
print("Screenshot timed out (skipping)")
# Detect contenteditable
print("\n--- Detecting contenteditable elements ---")
detection = await bridge.evaluate(
tab_id,
"""
(function() {
const editables = document.querySelectorAll('[contenteditable="true"]');
return {
count: editables.length,
ids: Array.from(editables).map(el => el.id)
};
})();
""",
)
print(f"Contenteditable detection: {detection.get('result', {})}")
# Test 1: Type into regular input (baseline)
print("\n--- Test 1: Regular input ---")
await bridge.click(tab_id, "#input1")
await bridge.type_text(tab_id, "#input1", "Hello input")
input_result = await bridge.evaluate(
tab_id, "(function() { return document.getElementById('input1').value; })()"
)
print(f"Input value: {input_result.get('result', '')}")
# Test 2: Type into contenteditable div
print("\n--- Test 2: Contenteditable div ---")
await bridge.click(tab_id, "#editor1")
await bridge.type_text(tab_id, "#editor1", "Hello contenteditable", clear_first=True)
editor_result = await bridge.evaluate(
tab_id,
"(function() { return document.getElementById('editor1').innerText; })()",
)
print(f"Editor1 innerText: {editor_result.get('result', '')}")
# Test 3: Use JavaScript insertText for rich editor
print("\n--- Test 3: JavaScript insertText for rich editor ---")
insert_result = await bridge.evaluate(
tab_id,
"""
(function() {
const editor = document.getElementById('editor2');
editor.focus();
document.execCommand('selectAll', false, null);
document.execCommand('insertText', false, 'Hello from execCommand');
return editor.innerText;
})();
""",
)
print(f"Editor2 after execCommand: {insert_result.get('result', '')}")
# Screenshot after with timeout protection
try:
screenshot_after = await asyncio.wait_for(bridge.screenshot(tab_id), timeout=10.0)
print(f"Screenshot after: {len(screenshot_after.get('data', ''))} bytes")
except asyncio.TimeoutError:
print("Screenshot after timed out (skipping)")
# Results
print("\n--- Results ---")
input_val = input_result.get("result", "")
editor1_val = editor_result.get("result", "")
editor2_val = insert_result.get("result", "")
input_pass = "Hello input" in input_val
editor1_pass = "Hello contenteditable" in editor1_val
editor2_pass = "execCommand" in editor2_val
print(f"Input: {'✓ PASS' if input_pass else '✗ FAIL'} - {input_val}")
print(f"Editor1: {'✓ PASS' if editor1_pass else '✗ FAIL'} - {editor1_val}")
print(f"Editor2: {'✓ PASS' if editor2_pass else '✗ FAIL'} - {editor2_val}")
await bridge.destroy_context(group_id)
print("\n✓ Context destroyed")
finally:
await bridge.stop()
if __name__ == "__main__":
asyncio.run(test_contenteditable())
@@ -0,0 +1,250 @@
#!/usr/bin/env python
"""
Test #8: Autocomplete Field Clearing
Symptom: Typed text gets cleared immediately
Root Cause: Field expects realistic keystroke timing for autocomplete
Detection: Field has autocomplete listeners or dropdown appears
Fix: Add delay_ms between keystrokes
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
CONTEXT_NAME = "autocomplete-test"
async def test_autocomplete():
"""Test typing into fields with autocomplete behavior."""
print("=" * 70)
print("TEST #8: Autocomplete Field Clearing")
print("=" * 70)
bridge = BeelineBridge()
try:
await bridge.start()
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
else:
print("✗ Extension not connected")
return
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
# Create test page with autocomplete behavior
test_html = """
<!DOCTYPE html>
<html>
<head><title>Autocomplete Test</title>
<style>
.autocomplete-items {
position: absolute;
border: 1px solid #d4d4d4;
border-top: none;
z-index: 99;
top: 100%;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: white;
}
.autocomplete-items div {
padding: 10px;
cursor: pointer;
}
.autocomplete-items div:hover {
background-color: #e9e9e9;
}
.autocomplete-active {
background-color: DodgerBlue !important;
color: white;
}
.autocomplete { position: relative; display: inline-block; }
input { width: 300px; padding: 10px; font-size: 16px; }
</style></head>
<body>
<h2>Autocomplete Test</h2>
<div class="autocomplete">
<input id="search" type="text" placeholder="Search countries..." autocomplete="off">
</div>
<div id="log" style="margin-top:20px;font-family:monospace;"></div>
<script>
const countries = [
"Afghanistan","Albania","Algeria",
"Andorra","Angola","Argentina",
"Armenia","Australia","Austria",
"Azerbaijan","Bahamas","Bahrain",
"Bangladesh","Belarus","Belgium",
"Belize","Benin","Bhutan",
"Bolivia","Brazil","Canada",
"China","Colombia","Denmark",
"Egypt","France","Germany",
"India","Indonesia","Italy",
"Japan","Mexico","Netherlands",
"Nigeria","Norway","Pakistan",
"Peru","Philippines","Poland",
"Portugal","Russia","Spain",
"Sweden","Switzerland","Thailand",
"Turkey","Ukraine",
"United Kingdom","United States",
"Vietnam"
];
const input = document.getElementById('search');
const log = document.getElementById('log');
let currentFocus = -1;
let typingTimeout = null;
// Track events for testing
window.inputEvents = [];
window.inputValue = '';
function logEvent(type, value) {
window.inputEvents.push({ type, value, time: Date.now() });
const entry = document.createElement('div');
entry.textContent = type + ': ' + value;
log.insertBefore(entry, log.firstChild);
}
// Simulate autocomplete that clears fast typing
input.addEventListener('input', function(e) {
const val = this.value;
// Clear previous dropdown
closeAllLists();
if (!val) return;
// If typing too fast (autocomplete-style), clear and restart
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
logEvent('input', val);
window.inputValue = val;
// Create dropdown
const div = document.createElement('div');
div.setAttribute('id', this.id + 'autocomplete-list');
div.setAttribute('class', 'autocomplete-items');
this.parentNode.appendChild(div);
countries.filter(
c => c.substr(0, val.length).toUpperCase()
=== val.toUpperCase()
).slice(0, 5).forEach(country => {
const item = document.createElement('div');
item.innerHTML = '<strong>'
+ country.substr(0, val.length)
+ '</strong>'
+ country.substr(val.length);
item.addEventListener('click', function() {
input.value = country;
closeAllLists();
logEvent('select', country);
window.inputValue = country;
});
div.appendChild(item);
});
}, 100); // 100ms debounce
});
function closeAllLists() {
document.querySelectorAll('.autocomplete-items').forEach(el => el.remove());
}
document.addEventListener('click', function() {
closeAllLists();
});
</script>
</body>
</html>
"""
# Write to file and use file:// URL (data: URLs don't work well with extension)
test_file = Path("/tmp/autocomplete_test.html")
test_file.write_text(test_html.strip())
file_url = f"file://{test_file}"
await bridge.navigate(tab_id, file_url, wait_until="load")
print("✓ Page loaded")
# Screenshot
screenshot = await bridge.screenshot(tab_id)
print(f"Screenshot: {len(screenshot.get('data', ''))} bytes")
# Test 1: Fast typing (no delay) - may fail
print("\n--- Test 1: Fast typing (delay_ms=0) ---")
await bridge.click(tab_id, "#search")
await bridge.type_text(tab_id, "#search", "Ger", clear_first=True, delay_ms=0)
await asyncio.sleep(0.5)
fast_result = await bridge.evaluate(
tab_id, "(function() { return document.getElementById('search').value; })()"
)
fast_value = fast_result.get("result", "")
print(f"Value after fast typing: '{fast_value}'")
# Check events
events_result = await bridge.evaluate(tab_id, "(function() { return window.inputEvents; })()")
print(f"Events logged: {events_result.get('result', [])}")
# Test 2: Slow typing (with delay) - should work
print("\n--- Test 2: Slow typing (delay_ms=100) ---")
await bridge.click(tab_id, "#search")
await bridge.type_text(tab_id, "#search", "United", clear_first=True, delay_ms=100)
await asyncio.sleep(0.5)
slow_result = await bridge.evaluate(
tab_id, "(function() { return document.getElementById('search').value; })()"
)
slow_value = slow_result.get("result", "")
print(f"Value after slow typing: '{slow_value}'")
# Check if dropdown appeared
dropdown_result = await bridge.evaluate(
tab_id,
"(function() { return document.querySelectorAll('.autocomplete-items div').length; })()",
)
dropdown_count = dropdown_result.get("result", 0)
print(f"Dropdown items: {dropdown_count}")
# Screenshot with dropdown
screenshot_dropdown = await bridge.screenshot(tab_id)
print(f"Screenshot with dropdown: {len(screenshot_dropdown.get('data', ''))} bytes")
# Results
print("\n--- Results ---")
if "United" in slow_value:
print("✓ PASS: Slow typing with delay_ms worked")
else:
print("✗ FAIL: Slow typing still didn't work")
if dropdown_count > 0:
print("✓ PASS: Autocomplete dropdown appeared")
else:
print("⚠ WARNING: No autocomplete dropdown")
await bridge.destroy_context(group_id)
print("\n✓ Context destroyed")
finally:
await bridge.stop()
if __name__ == "__main__":
asyncio.run(test_autocomplete())
@@ -0,0 +1,156 @@
#!/usr/bin/env python
"""
Test #10: LinkedIn Huge DOM Tree
Symptom: browser_snapshot() hangs forever
Root Cause: 10k+ DOM nodes, accessibility tree has 50k+ nodes
Detection: document.querySelectorAll('*').length > 5000
Fix: Add timeout (10s default), truncate tree at 2000 nodes
"""
import asyncio
import sys
import time
import base64
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
CONTEXT_NAME = "huge-dom-test"
async def test_huge_dom():
"""Test snapshot performance on huge DOM trees."""
print("=" * 70)
print("TEST #10: Huge DOM Tree (LinkedIn-style)")
print("=" * 70)
bridge = BeelineBridge()
try:
await bridge.start()
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
else:
print("✗ Extension not connected")
return
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
# Test 1: Small DOM (baseline)
print("\n--- Test 1: Small DOM (baseline) ---")
small_html = """
<!DOCTYPE html>
<html><body>
<h1>Small Page</h1>
<p>A few elements</p>
<button>Click me</button>
</body></html>
"""
data_url = f"data:text/html;base64,{base64.b64encode(small_html.encode()).decode()}"
await bridge.navigate(tab_id, data_url, wait_until="load")
start = time.perf_counter()
snapshot = await bridge.snapshot(tab_id, timeout_s=5.0)
elapsed = time.perf_counter() - start
tree_len = len(snapshot.get("tree", ""))
print(f"Small DOM snapshot: {elapsed:.3f}s, {tree_len} chars")
# Test 2: Generate huge DOM
print("\n--- Test 2: Huge DOM (5000+ elements) ---")
huge_html = """
<!DOCTYPE html>
<html><body>
<h1>Huge DOM Test</h1>
<div id="container"></div>
<script>
const container = document.getElementById('container');
for (let i = 0; i < 5000; i++) {
const div = document.createElement('div');
div.className = 'item-' + i;
div.innerHTML = '<span>Item ' + i + '</span><button>Action</button>';
container.appendChild(div);
}
</script>
</body></html>
"""
data_url = f"data:text/html;base64,{base64.b64encode(huge_html.encode()).decode()}"
await bridge.navigate(tab_id, data_url, wait_until="load")
# Count elements
count_result = await bridge.evaluate(tab_id, "(function() { return document.querySelectorAll('*').length; })()")
elem_count = count_result.get("result", 0)
print(f"DOM elements: {elem_count}")
# Skip screenshot on huge DOM - it can timeout
# Instead verify page loaded by checking DOM
print("✓ Page verified (skipping screenshot on huge DOM)")
# Test snapshot with timeout
print("\n--- Testing snapshot with 10s timeout ---")
start = time.perf_counter()
try:
snapshot = await bridge.snapshot(tab_id, timeout_s=10.0)
elapsed = time.perf_counter() - start
tree_len = len(snapshot.get("tree", ""))
truncated = "(truncated)" in snapshot.get("tree", "")
print(f"✓ Huge DOM snapshot: {elapsed:.3f}s, {tree_len} chars, truncated={truncated}")
if elapsed < 5.0:
print("✓ PASS: Snapshot completed quickly")
else:
print(f"⚠ WARNING: Snapshot took {elapsed:.1f}s")
if truncated:
print("✓ PASS: Tree was truncated to prevent hang")
else:
print("⚠ WARNING: Tree not truncated (may need adjustment)")
except asyncio.TimeoutError:
print("✗ FAIL: Snapshot timed out (this shouldn't happen)")
# Test 3: Real LinkedIn
print("\n--- Test 3: Real LinkedIn Feed ---")
await bridge.navigate(tab_id, "https://www.linkedin.com/feed", wait_until="load", timeout_ms=30000)
await asyncio.sleep(2)
count_result = await bridge.evaluate(tab_id, "(function() { return document.querySelectorAll('*').length; })()")
elem_count = count_result.get("result", 0)
print(f"LinkedIn DOM elements: {elem_count}")
start = time.perf_counter()
try:
snapshot = await bridge.snapshot(tab_id, timeout_s=15.0)
elapsed = time.perf_counter() - start
tree_len = len(snapshot.get("tree", ""))
truncated = "(truncated)" in snapshot.get("tree", "")
print(f"LinkedIn snapshot: {elapsed:.3f}s, {tree_len} chars, truncated={truncated}")
if elapsed < 5.0:
print("✓ PASS: LinkedIn snapshot fast enough")
elif elapsed < 15.0:
print("⚠ WARNING: LinkedIn snapshot slow but within timeout")
else:
print("✗ FAIL: LinkedIn snapshot too slow")
except asyncio.TimeoutError:
print("✗ FAIL: LinkedIn snapshot timed out")
await bridge.destroy_context(group_id)
print("\n✓ Context destroyed")
finally:
await bridge.stop()
if __name__ == "__main__":
asyncio.run(test_huge_dom())
@@ -0,0 +1,190 @@
#!/usr/bin/env python
"""
Test #13: SPA Navigation Events
Symptom: wait_until="load" fires before content ready
Root Cause: SPA uses client-side routing, no full page load
Detection: URL changes but load event already fired
Fix: Use wait_until="networkidle" or wait_for_selector
"""
import asyncio
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
CONTEXT_NAME = "spa-nav-test"
async def test_spa_navigation():
"""Test navigation timing on SPA pages."""
print("=" * 70)
print("TEST #13: SPA Navigation Events")
print("=" * 70)
bridge = BeelineBridge()
try:
await bridge.start()
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
else:
print("✗ Extension not connected")
return
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
# Create a test SPA
spa_html = """
<!DOCTYPE html>
<html>
<head>
<title>SPA Test</title>
<style>
nav a { margin-right: 10px; }
.page { padding: 20px; border: 1px solid #ccc; margin-top: 10px; }
</style>
</head>
<body>
<nav>
<a href="#home" onclick="navigate('home')">Home</a>
<a href="#about" onclick="navigate('about')">About</a>
<a href="#contact" onclick="navigate('contact')">Contact</a>
</nav>
<div id="app" class="page">
<h1>Loading...</h1>
</div>
<script>
// Simulate SPA routing
let currentPage = '';
async function navigate(page) {
event.preventDefault();
currentPage = page;
// Show loading state
document.getElementById('app').innerHTML = '<h1>Loading...</h1>';
// Simulate async content loading (like real SPAs)
await new Promise(r => setTimeout(r, 500));
// Render content
const content = {
home: '<h1>Home Page</h1><p>Welcome!</p>'
+ '<button id="home-btn">Home Action</button>',
about: '<h1>About Page</h1><p>Simulated SPA.</p>'
+ '<button id="about-btn">About Action</button>',
contact: '<h1>Contact Page</h1>'
+ '<p>Contact us at test@example.com</p>'
+ '<button id="contact-btn">Contact Action</button>'
};
document.getElementById('app').innerHTML = content[page] || '<h1>404</h1>';
window.location.hash = page;
}
// Initial load with delay (simulates SPA hydration)
setTimeout(() => {
navigate('home');
}, 1000);
// Track for testing
window.pageLoads = [];
window.addEventListener('hashchange', () => {
window.pageLoads.push(window.location.hash);
});
</script>
</body>
</html>
"""
# Write to file and use file:// URL (data: URLs don't work well with extension)
test_file = Path("/tmp/spa_test.html")
test_file.write_text(spa_html.strip())
file_url = f"file://{test_file}"
# Test 1: wait_until="load" - may fire before content ready
print("\n--- Test 1: wait_until='load' ---")
start = time.perf_counter()
await bridge.navigate(tab_id, file_url, wait_until="load")
elapsed = time.perf_counter() - start
print(f"Navigation completed in {elapsed:.3f}s")
# Check content immediately
content = await bridge.evaluate(
tab_id,
"(function() { return document.getElementById('app').innerText; })()",
)
print(f"Content immediately after load: '{content.get('result', '')}'")
# Screenshot
screenshot = await bridge.screenshot(tab_id)
print(f"Screenshot: {len(screenshot.get('data', ''))} bytes")
# Wait for content
print("\n--- Waiting for content to hydrate ---")
await bridge.wait_for_selector(tab_id, "#home-btn", timeout_ms=5000)
print("✓ Content loaded")
# Check content after wait
content_after = await bridge.evaluate(
tab_id,
"(function() { return document.getElementById('app').innerText; })()",
)
print(f"Content after wait: '{content_after.get('result', '')}'")
# Test 2: SPA navigation (no full page load)
print("\n--- Test 2: SPA client-side navigation ---")
# Click "About" link
await bridge.click(tab_id, 'a[href="#about"]')
await asyncio.sleep(1)
# Check if content changed
about_content = await bridge.evaluate(
tab_id,
"(function() { return document.getElementById('app').innerText; })()",
)
print(f"Content after SPA nav: '{about_content.get('result', '')}'")
if "About Page" in about_content.get("result", ""):
print("✓ PASS: SPA navigation worked")
else:
print("✗ FAIL: SPA navigation didn't update content")
# Test 3: wait_until="networkidle"
print("\n--- Test 3: wait_until='networkidle' ---")
await bridge.navigate(tab_id, file_url, wait_until="networkidle", timeout_ms=10000)
# Check content immediately
content_networkidle = await bridge.evaluate(
tab_id,
"(function() { return document.getElementById('app').innerText; })()",
)
print(f"Content after networkidle: '{content_networkidle.get('result', '')}'")
if "Home Page" in content_networkidle.get("result", ""):
print("✓ PASS: networkidle waited for content")
else:
print("⚠ WARNING: networkidle didn't wait long enough")
await bridge.destroy_context(group_id)
print("\n✓ Context destroyed")
finally:
await bridge.stop()
if __name__ == "__main__":
asyncio.run(test_spa_navigation())
@@ -0,0 +1,262 @@
#!/usr/bin/env python
"""
Test #15: Screenshot Functionality
Tests browser_screenshot across multiple scenarios:
- Basic viewport screenshot
- Full-page screenshot
- Selector-based screenshot
- Screenshot on complex DOM
- Timeout handling
Category: screenshot
"""
import asyncio
import base64
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
CONTEXT_NAME = "screenshot-test"
SIMPLE_HTML = """<!DOCTYPE html>
<html>
<head><style>
body { margin: 0; background: #fff; font-family: sans-serif; }
h1 { color: #333; padding: 20px; }
.box { width: 200px; height: 100px; background: #4a90e2; margin: 20px; }
.long-content { height: 2000px; background: linear-gradient(blue, red); }
</style></head>
<body>
<h1 id="title">Screenshot Test Page</h1>
<div class="box" id="target-box">Target Box</div>
<div class="long-content"></div>
</body>
</html>"""
def check_png(data: str) -> bool:
"""Verify that base64 data decodes to a valid PNG."""
try:
raw = base64.b64decode(data)
return raw[:8] == b"\x89PNG\r\n\x1a\n"
except Exception:
return False
async def test_basic_screenshot(bridge: BeelineBridge, tab_id: int, data_url: str):
print("\n--- Test 1: Basic Viewport Screenshot ---")
await bridge.navigate(tab_id, data_url, wait_until="load")
await asyncio.sleep(0.5)
start = time.perf_counter()
result = await bridge.screenshot(tab_id)
elapsed = time.perf_counter() - start
ok = result.get("ok")
data = result.get("data", "")
mime = result.get("mimeType", "")
print(f" ok={ok}, mimeType={mime}, elapsed={elapsed:.3f}s")
print(f" data length: {len(data)} chars")
if ok and data:
valid_png = check_png(data)
print(f" valid PNG: {valid_png}")
if valid_png:
raw = base64.b64decode(data)
print(f" PNG size: {len(raw)} bytes")
print(" ✓ PASS: Basic screenshot works")
return True
else:
print(" ✗ FAIL: Data is not a valid PNG")
else:
print(f" ✗ FAIL: {result.get('error', 'no data')}")
return False
async def test_full_page_screenshot(bridge: BeelineBridge, tab_id: int, data_url: str):
print("\n--- Test 2: Full Page Screenshot ---")
await bridge.navigate(tab_id, data_url, wait_until="load")
await asyncio.sleep(0.5)
viewport_result = await bridge.screenshot(tab_id, full_page=False)
full_result = await bridge.screenshot(tab_id, full_page=True)
v_data = viewport_result.get("data", "")
f_data = full_result.get("data", "")
if not v_data or not f_data:
print(f" ✗ FAIL: viewport ok={viewport_result.get('ok')}, full ok={full_result.get('ok')}")
return False
v_size = len(base64.b64decode(v_data))
f_size = len(base64.b64decode(f_data))
print(f" Viewport PNG: {v_size} bytes")
print(f" Full page PNG: {f_size} bytes")
if f_size > v_size:
print(" ✓ PASS: Full page larger than viewport")
return True
else:
print(" ✗ FAIL: Full page not larger than viewport (may not capture long pages)")
return False
async def test_selector_screenshot(bridge: BeelineBridge, tab_id: int, data_url: str):
print("\n--- Test 3: Selector Screenshot ---")
await bridge.navigate(tab_id, data_url, wait_until="load")
await asyncio.sleep(0.5)
# selector param exists in signature but may not be implemented
result = await bridge.screenshot(tab_id, selector="#target-box")
ok = result.get("ok")
data = result.get("data", "")
if ok and data:
# If implemented, the box screenshot should be smaller than a full viewport screenshot
full_result = await bridge.screenshot(tab_id)
full_data = full_result.get("data", "")
if full_data:
sel_size = len(base64.b64decode(data))
full_size = len(base64.b64decode(full_data))
print(f" Selector PNG: {sel_size} bytes")
print(f" Full page PNG: {full_size} bytes")
if sel_size < full_size:
print(" ✓ PASS: Selector screenshot smaller than full page")
return True
else:
print(" ⚠ WARNING: Selector screenshot not smaller (may be full page)")
return False
else:
print(f" ⚠ NOT IMPLEMENTED: selector param ignored (returns full page) - error={result.get('error')}")
print(" NOTE: selector parameter exists in signature but is not used in implementation")
return False
async def test_screenshot_url_metadata(bridge: BeelineBridge, tab_id: int):
print("\n--- Test 4: Screenshot URL Metadata ---")
await bridge.navigate(tab_id, "https://example.com", wait_until="load")
await asyncio.sleep(1)
result = await bridge.screenshot(tab_id)
url = result.get("url", "")
tab = result.get("tabId")
print(f" url={url!r}, tabId={tab}")
if "example.com" in url:
print(" ✓ PASS: URL metadata captured correctly")
return True
else:
print(f" ✗ FAIL: Expected example.com in URL, got {url!r}")
return False
async def test_screenshot_timeout(bridge: BeelineBridge, tab_id: int, data_url: str):
print("\n--- Test 5: Timeout Handling ---")
await bridge.navigate(tab_id, data_url, wait_until="load")
# Very short timeout - likely still completes since simple page
start = time.perf_counter()
result = await bridge.screenshot(tab_id, timeout_s=0.001)
elapsed = time.perf_counter() - start
if not result.get("ok"):
err = result.get("error", "")
if "timed out" in err or "cancelled" in err:
print(f" ✓ PASS: Timeout handled gracefully: {err!r}")
return True
else:
print(f" ⚠ Fast enough to beat timeout: {err!r} in {elapsed:.3f}s")
return True # Not a failure, just fast
else:
print(f" ⚠ Screenshot completed before timeout ({elapsed:.3f}s) - too fast to test timeout")
return True # Still ok, just very fast
async def test_screenshot_complex_site(bridge: BeelineBridge, tab_id: int):
print("\n--- Test 6: Complex Site (example.com) ---")
await bridge.navigate(tab_id, "https://example.com", wait_until="load")
await asyncio.sleep(1)
start = time.perf_counter()
result = await bridge.screenshot(tab_id)
elapsed = time.perf_counter() - start
ok = result.get("ok")
data = result.get("data", "")
print(f" ok={ok}, elapsed={elapsed:.3f}s, data_len={len(data)}")
if ok and check_png(data):
print(" ✓ PASS: Screenshot on real site works")
return True
else:
print(f" ✗ FAIL: {result.get('error', 'bad data')}")
return False
async def main():
print("=" * 70)
print("TEST #15: Screenshot Functionality")
print("=" * 70)
bridge = BeelineBridge()
try:
await bridge.start()
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
print(f"Waiting for extension... ({i + 1}/10)")
else:
print("✗ Extension not connected. Ensure Chrome with Beeline extension is running.")
return
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
data_url = f"data:text/html;base64,{base64.b64encode(SIMPLE_HTML.encode()).decode()}"
results = {
"basic": await test_basic_screenshot(bridge, tab_id, data_url),
"full_page": await test_full_page_screenshot(bridge, tab_id, data_url),
"selector": await test_selector_screenshot(bridge, tab_id, data_url),
"metadata": await test_screenshot_url_metadata(bridge, tab_id),
"timeout": await test_screenshot_timeout(bridge, tab_id, data_url),
"complex_site": await test_screenshot_complex_site(bridge, tab_id),
}
print("\n" + "=" * 70)
print("SUMMARY")
print("=" * 70)
for name, passed in results.items():
status = "✓ PASS" if passed else "✗ FAIL"
print(f" {status}: {name}")
passed_count = sum(1 for v in results.values() if v)
total = len(results)
print(f"\n {passed_count}/{total} tests passed")
await bridge.destroy_context(group_id)
print("\n✓ Context destroyed")
finally:
await bridge.stop()
print("✓ Bridge stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,327 @@
#!/usr/bin/env python
"""
Browser Edge Case Test Template
This script provides a template for testing and debugging browser tool failures
on specific websites. Use this to reproduce, isolate, and verify fixes.
Usage:
1. Copy this file: cp test_case.py test_#[number]_[site].py
2. Fill in the CONFIG section with your test details
3. Run: uv run python test_#[number]_[site].py
Example:
uv run python test_01_linkedin_scroll.py
"""
import asyncio
import sys
import time
from pathlib import Path
# Add tools to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
from gcu.browser.bridge import BeelineBridge
# ═══════════════════════════════════════════════════════════════════════════════
# CONFIG: Fill in these values for your test case
# ═══════════════════════════════════════════════════════════════════════════════
TEST_CASE = {
"number": 1,
"name": "LinkedIn Nested Scroll Container",
"site": "https://www.linkedin.com/feed",
"simple_site": "https://example.com",
"category": "scroll", # scroll, click, input, snapshot, navigation
"symptom": "scroll() returns success but page doesn't move",
}
BRIDGE_PORT = 9229
CONTEXT_NAME = "edge-case-test"
# ═══════════════════════════════════════════════════════════════════════════════
# TEST FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════════
async def test_simple_site(bridge: BeelineBridge, tab_id: int) -> dict:
"""Test that the tool works on a simple site (baseline)."""
print("\n--- Baseline Test (Simple Site) ---")
await bridge.navigate(tab_id, TEST_CASE["simple_site"], wait_until="load")
await asyncio.sleep(1)
# Adjust this based on category
if TEST_CASE["category"] == "scroll":
result = await bridge.scroll(tab_id, "down", 100)
print(f" Scroll result: {result}")
return result
elif TEST_CASE["category"] == "click":
# Add click test
pass
elif TEST_CASE["category"] == "snapshot":
result = await bridge.snapshot(tab_id, timeout_s=5.0)
print(f" Snapshot length: {len(result.get('tree', ''))}")
return result
return {"ok": True}
async def test_problematic_site(bridge: BeelineBridge, tab_id: int) -> dict:
"""Test the tool on the problematic site."""
print("\n--- Problem Site Test ---")
await bridge.navigate(tab_id, TEST_CASE["site"], wait_until="load", timeout_ms=30000)
await asyncio.sleep(2)
# Adjust this based on category
if TEST_CASE["category"] == "scroll":
# Get scroll positions before
before = await bridge.evaluate(
tab_id,
"""
(function() {
const results = { window: { y: window.scrollY } };
document.querySelectorAll('*').forEach((el, i) => {
const style = getComputedStyle(el);
if ((style.overflowY === 'scroll' || style.overflowY === 'auto') &&
el.scrollHeight > el.clientHeight) {
results['el_' + i] = {
tag: el.tagName,
scrollTop: el.scrollTop,
class: el.className.substring(0, 30)
};
}
});
return results;
})();
""",
)
print(f" Before scroll: {before.get('result', {})}")
# Try to scroll
result = await bridge.scroll(tab_id, "down", 500)
print(f" Scroll result: {result}")
await asyncio.sleep(1)
# Get scroll positions after
after = await bridge.evaluate(
tab_id,
"""
(function() {
const results = { window: { y: window.scrollY } };
document.querySelectorAll('*').forEach((el, i) => {
const style = getComputedStyle(el);
if ((style.overflowY === 'scroll' || style.overflowY === 'auto') &&
el.scrollHeight > el.clientHeight) {
results['el_' + i] = {
tag: el.tagName,
scrollTop: el.scrollTop,
class: el.className.substring(0, 30)
};
}
});
return results;
})();
""",
)
print(f" After scroll: {after.get('result', {})}")
# Check if anything changed
before_data = before.get("result", {}) or {}
after_data = after.get("result", {}) or {}
changed = False
for key in after_data:
if key in before_data:
b_val = before_data[key].get("scrollTop", 0) if isinstance(before_data[key], dict) else 0
a_val = after_data[key].get("scrollTop", 0) if isinstance(after_data[key], dict) else 0
if a_val != b_val:
print(f" ✓ CHANGE DETECTED: {key} scrolled from {b_val} to {a_val}")
changed = True
if not changed:
print(" ✗ NO CHANGE: Scroll did not affect any container")
return {"ok": changed, "scroll_result": result}
elif TEST_CASE["category"] == "snapshot":
start = time.perf_counter()
try:
result = await bridge.snapshot(tab_id, timeout_s=15.0)
elapsed = time.perf_counter() - start
tree_len = len(result.get("tree", ""))
print(f" Snapshot completed in {elapsed:.2f}s, {tree_len} chars")
return {"ok": True, "elapsed": elapsed, "tree_length": tree_len}
except asyncio.TimeoutError:
print(" ✗ SNAPSHOT TIMED OUT")
return {"ok": False, "error": "timeout"}
return {"ok": True}
async def detect_root_cause(bridge: BeelineBridge, tab_id: int) -> dict:
"""Run detection scripts to identify the root cause."""
print("\n--- Root Cause Detection ---")
detections = {}
# Detection 1: Nested scrollable containers
scroll_check = await bridge.evaluate(
tab_id,
"""
(function() {
const candidates = [];
document.querySelectorAll('*').forEach(el => {
const style = getComputedStyle(el);
if (style.overflow.includes('scroll') || style.overflow.includes('auto')) {
const rect = el.getBoundingClientRect();
if (rect.width > 100 && rect.height > 100) {
candidates.push({
tag: el.tagName,
area: rect.width * rect.height,
class: el.className.substring(0, 30)
});
}
}
});
candidates.sort((a, b) => b.area - a.area);
return {
count: candidates.length,
largest: candidates[0]
};
})();
""",
)
detections["nested_scroll"] = scroll_check.get("result", {})
print(f" Nested scroll containers: {detections['nested_scroll']}")
# Detection 2: Shadow DOM
shadow_check = await bridge.evaluate(
tab_id,
"""
(function() {
const withShadow = [];
document.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) {
withShadow.push(el.tagName);
}
});
return { count: withShadow.length, elements: withShadow.slice(0, 5) };
})();
""",
)
detections["shadow_dom"] = shadow_check.get("result", {})
print(f" Shadow DOM: {detections['shadow_dom']}")
# Detection 3: iframes
iframe_check = await bridge.evaluate(
tab_id,
"""
(function() {
const iframes = document.querySelectorAll('iframe');
return { count: iframes.length };
})();
""",
)
detections["iframes"] = iframe_check.get("result", {})
print(f" iframes: {detections['iframes']}")
# Detection 4: DOM size
dom_check = await bridge.evaluate(
tab_id,
"""
(function() {
return {
elements: document.querySelectorAll('*').length,
body_children: document.body.children.length
};
})();
""",
)
detections["dom_size"] = dom_check.get("result", {})
print(f" DOM size: {detections['dom_size']}")
# Detection 5: Framework detection
framework_check = await bridge.evaluate(
tab_id,
"""
(function() {
return {
react: !!document.querySelector('[data-reactroot], [data-reactid]'),
vue: !!document.querySelector('[data-v-]'),
angular: !!document.querySelector('[ng-app], [ng-version]')
};
})();
""",
)
detections["frameworks"] = framework_check.get("result", {})
print(f" Frameworks: {detections['frameworks']}")
return detections
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════════
async def main():
print("=" * 70)
print(f"EDGE CASE TEST #{TEST_CASE['number']}: {TEST_CASE['name']}")
print("=" * 70)
print(f"Site: {TEST_CASE['site']}")
print(f"Category: {TEST_CASE['category']}")
print(f"Symptom: {TEST_CASE['symptom']}")
bridge = BeelineBridge()
try:
print("\n--- Starting Bridge ---")
await bridge.start()
# Wait for extension connection
for i in range(10):
await asyncio.sleep(1)
if bridge.is_connected:
print("✓ Extension connected!")
break
print(f"Waiting for extension... ({i + 1}/10)")
else:
print("✗ Extension not connected. Ensure Chrome with Beeline extension is running.")
return
# Create browser context
context = await bridge.create_context(CONTEXT_NAME)
tab_id = context.get("tabId")
group_id = context.get("groupId")
print(f"✓ Created tab: {tab_id}")
# Run tests
baseline_result = await test_simple_site(bridge, tab_id)
problem_result = await test_problematic_site(bridge, tab_id)
detections = await detect_root_cause(bridge, tab_id)
# Summary
print("\n" + "=" * 70)
print("SUMMARY")
print("=" * 70)
print(f"Baseline test: {'✓ PASS' if baseline_result.get('ok') else '✗ FAIL'}")
print(f"Problem test: {'✓ PASS' if problem_result.get('ok') else '✗ FAIL'}")
print(f"Root cause indicators: {list(k for k, v in detections.items() if v)}")
# Cleanup
print("\n--- Cleanup ---")
await bridge.destroy_context(group_id)
print("✓ Context destroyed")
finally:
await bridge.stop()
print("✓ Bridge stopped")
if __name__ == "__main__":
asyncio.run(main())
+225
View File
@@ -0,0 +1,225 @@
# Integration Test Reporting Skill
Run the Level 2 dummy agent integration test suite and produce a detailed HTML report with per-test input → outcome analysis.
## Trigger
User wants to run integration tests and see results:
- `/test-reporting`
- `/test-reporting test_component_queen_live.py`
- `/test-reporting --all`
## SOP: Running Tests
### Step 1: Select Scope
If the user provides a specific test file or pattern, use it. Otherwise run the full suite.
```bash
# Full suite
cd core && echo "1" | uv run python tests/dummy_agents/run_all.py --interactive 2>&1
# Specific file (requires manual provider setup)
cd core && uv run python -c "
import sys
sys.path.insert(0, '.')
from tests.dummy_agents.run_all import detect_available
from tests.dummy_agents.conftest import set_llm_selection
avail = detect_available()
claude = [p for p in avail if 'Claude Code' in p['name']]
if not claude:
avail_names = [p['name'] for p in avail]
raise RuntimeError(f'No Claude Code subscription. Available: {avail_names}')
provider = claude[0]
set_llm_selection(
model=provider['model'],
api_key=provider['api_key'],
extra_headers=provider.get('extra_headers'),
api_base=provider.get('api_base'),
)
import pytest
sys.exit(pytest.main([
'tests/dummy_agents/TEST_FILE_HERE',
'-v', '--override-ini=asyncio_mode=auto', '--no-header', '--tb=long',
'--log-cli-level=WARNING', '--junitxml=/tmp/hive_test_results.xml',
]))
"
```
### Step 2: Collect Results
After the test run completes, collect:
1. **JUnit XML** from `--junitxml` output (if available)
2. **stdout/stderr** from the run
3. **Summary table** from `run_all.py` output (the Unicode table)
### Step 3: Generate HTML Report
Write the report to `/tmp/hive_integration_test_report.html`.
The report MUST include these sections:
#### Header
- Run timestamp (ISO 8601)
- Provider used (model name, source)
- Total tests / passed / failed / skipped
- Total wall-clock time
- Overall verdict: PASS (all green) or FAIL (with count)
#### Per-Test Table
For EVERY test (not just failures), include a row with:
| Column | Description |
|--------|-------------|
| Component | Test file grouping (e.g., `component_queen_live`) |
| Test Name | Function name (e.g., `test_queen_starts_in_planning_without_worker`) |
| Status | PASS / FAIL / SKIP / ERROR with color badge |
| Duration | Wall-clock seconds |
| What | One-line description of what the test verifies |
| How | How it works (setup → action → assertion) |
| Why | Why this test matters (what bug/behavior it catches) |
| Input | The input data or configuration (graph spec, initial prompt, phase, etc.) |
| Expected Outcome | What the test asserts |
| Actual Outcome | What actually happened (PASS: matches expected / FAIL: actual vs expected) |
| Failure Detail | For failures only: full traceback + diagnosis |
#### What / How / Why Descriptions
These MUST be derived from the test function's docstring and code. Read each test file to extract:
- **What**: From the docstring first line
- **How**: From the test body (what fixtures, what graph, what assertions)
- **Why**: From the docstring body or "Why this matters" section in the test module
Use these mappings for the component test files:
```
test_component_llm.py → "LLM Provider" — streaming, tool calling, tokens
test_component_tools.py → "Tool Registry + MCP" — connection, execution
test_component_event_loop.py → "EventLoopNode" — iteration, output, stall
test_component_edges.py → "Edge Evaluation" — conditional, priority
test_component_conversation.py → "Conversation Persistence" — storage, cursor
test_component_escalation.py → "Escalation Flow" — worker→queen signaling
test_component_continuous.py → "Continuous Mode" — conversation threading
test_component_queen.py → "Queen Phase (Unit)" — phase state, tools, events
test_component_queen_live.py → "Queen Phase (Live)" — real queen, real LLM
test_component_queen_state_machine.py → "Queen State Machine" — edge cases, races
test_component_worker_comms.py → "Worker Communication" — events, data flow
test_component_strict_outcomes.py → "Strict Outcomes" — exact path, output, quality
```
#### HTML Template
Use this structure:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hive Integration Test Report — {timestamp}</title>
<style>
:root { --pass: #22c55e; --fail: #ef4444; --skip: #f59e0b; --bg: #0f172a; --surface: #1e293b; --text: #e2e8f0; --muted: #94a3b8; --border: #334155; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'SF Mono', 'Fira Code', monospace; background: var(--bg); color: var(--text); padding: 2rem; line-height: 1.6; }
h1, h2, h3 { font-weight: 600; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
h2 { font-size: 1.2rem; margin: 2rem 0 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.card { background: var(--surface); padding: 1rem; border-radius: 8px; border: 1px solid var(--border); }
.card .label { color: var(--muted); font-size: 0.75rem; text-transform: uppercase; }
.card .value { font-size: 1.5rem; font-weight: 700; margin-top: 0.25rem; }
.card .value.pass { color: var(--pass); }
.card .value.fail { color: var(--fail); }
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
th { background: var(--surface); position: sticky; top: 0; text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--border); color: var(--muted); text-transform: uppercase; font-size: 0.7rem; }
td { padding: 0.5rem; border-bottom: 1px solid var(--border); vertical-align: top; }
tr:hover { background: rgba(255,255,255,0.03); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; }
.badge.pass { background: rgba(34,197,94,0.2); color: var(--pass); }
.badge.fail { background: rgba(239,68,68,0.2); color: var(--fail); }
.badge.skip { background: rgba(245,158,11,0.2); color: var(--skip); }
.detail { background: #1a1a2e; padding: 0.75rem; border-radius: 4px; margin-top: 0.5rem; font-size: 0.75rem; white-space: pre-wrap; overflow-x: auto; max-height: 200px; overflow-y: auto; }
.component-header { background: var(--surface); padding: 0.75rem 0.5rem; font-weight: 600; font-size: 0.85rem; }
.meta { color: var(--muted); font-size: 0.75rem; }
</style>
</head>
<body>
<h1>Hive Integration Test Report</h1>
<p class="meta">Generated: {timestamp} | Provider: {provider} | Duration: {duration}s</p>
<div class="summary">
<div class="card"><div class="label">Total</div><div class="value">{total}</div></div>
<div class="card"><div class="label">Passed</div><div class="value pass">{passed}</div></div>
<div class="card"><div class="label">Failed</div><div class="value fail">{failed}</div></div>
<div class="card"><div class="label">Verdict</div><div class="value {verdict_class}">{verdict}</div></div>
</div>
<h2>Test Results</h2>
<table>
<thead>
<tr>
<th>Component</th>
<th>Test</th>
<th>Status</th>
<th>Time</th>
<th>What</th>
<th>Input → Expected → Actual</th>
</tr>
</thead>
<tbody>
<!-- For each test: -->
<tr>
<td>{component}</td>
<td>{test_name}</td>
<td><span class="badge {status_class}">{status}</span></td>
<td>{duration}s</td>
<td>{what_description}</td>
<td>
<strong>Input:</strong> {input_description}<br>
<strong>Expected:</strong> {expected_outcome}<br>
<strong>Actual:</strong> {actual_outcome}
<!-- If failed: -->
<div class="detail">{failure_traceback}</div>
</td>
</tr>
</tbody>
</table>
<h2>Failure Analysis</h2>
<!-- Only if there are failures -->
<p>For each failure, provide:</p>
<ul>
<li><strong>Root cause:</strong> Why it failed</li>
<li><strong>Impact:</strong> What this means for the system</li>
<li><strong>Suggested fix:</strong> How to address it</li>
</ul>
</body>
</html>
```
### Step 4: Output
1. Write the HTML file to `/tmp/hive_integration_test_report.html`
2. Print the file path so the user can open it
3. Print a concise summary to the terminal:
```
Test Report: /tmp/hive_integration_test_report.html
Result: 74/76 PASSED (2 failures)
Failures:
- parallel_merge::test_parallel_disjoint_output_keys
- worker::test_worker_timestamped_note_artifact
```
## Key Rules
1. ALWAYS use `--junitxml` when running pytest to get structured results
2. ALWAYS read the test source files to populate What/How/Why columns — do not guess
3. For Input/Expected/Actual, extract from the test's graph spec, assertions, and result
4. Color-code everything: green for pass, red for fail, amber for skip
5. Include the full traceback for failures in a scrollable `<div class="detail">`
6. Group tests by component (file name) with a visual separator
7. The report must be self-contained HTML (no external CSS/JS dependencies)
-18
View File
@@ -1,18 +0,0 @@
This project uses ruff for Python linting and formatting.
Rules:
- Line length: 100 characters
- Python target: 3.11+
- Use double quotes for strings
- Sort imports with isort (ruff I rules): stdlib, third-party, first-party (framework), local
- Combine as-imports
- Use type hints on all function signatures
- Use `from __future__ import annotations` for modern type syntax
- Raise exceptions with `from` in except blocks (B904)
- No unused imports (F401), no unused variables (F841)
- Prefer list/dict/set comprehensions over map/filter (C4)
Run `make lint` to auto-fix, `make check` to verify without modifying files.
Run `make format` to apply ruff formatting.
The ruff config lives in core/pyproject.toml under [tool.ruff].
-35
View File
@@ -1,35 +0,0 @@
# Git
.git/
.gitignore
# Documentation
*.md
docs/
LICENSE
# IDE
.idea/
.vscode/
# Dependencies (rebuilt in container)
node_modules/
# Build artifacts
dist/
build/
coverage/
# Environment files
.env*
config.yaml
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# GitHub
.github/
+3
View File
@@ -22,3 +22,6 @@ indent_size = 2
[Makefile]
indent_style = tab
[*.{sh,ps1}]
end_of_line = lf
+5 -1
View File
@@ -16,7 +16,6 @@
# Shell scripts (must use LF)
*.sh text eol=lf
quickstart.sh text eol=lf
# PowerShell scripts (Windows-friendly)
*.ps1 text eol=lf
@@ -122,3 +121,8 @@ CODE_OF_CONDUCT* text
*.db binary
*.sqlite binary
*.sqlite3 binary
# Lockfiles — mark generated so GitHub collapses them in PR diffs
*.lock linguist-generated=true -diff
package-lock.json linguist-generated=true -diff
uv.lock linguist-generated=true -diff
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
working-directory: core
run: |
uv sync
uv run pytest tests/ -v
uv run pytest tests/ -v --ignore=tests/dummy_agents
test-tools:
name: Test Tools (${{ matrix.os }})
+7 -2
View File
@@ -13,6 +13,10 @@ out/
.env
.env.local
.env.*.local
.venv
/venv
tools/src/uv.lock
# User configuration (copied from .example)
config.yaml
@@ -66,14 +70,15 @@ tmp/
temp/
exports/*
exports.old*
artifacts/*
.claude/settings.local.json
.venv
docs/github-issues/*
core/tests/*dumps/*
screenshots/*
.gemini/*
.coverage
@@ -0,0 +1,9 @@
{"type": "connection", "event": "connect", "ts": "2026-04-04T01:10:38.245667+00:00", "profile": "default"}
{"type": "connection", "event": "hello", "details": {"version": "1.0"}, "ts": "2026-04-04T01:10:38.247207+00:00", "profile": "default"}
{"type": "connection", "event": "disconnect", "ts": "2026-04-04T01:11:57.148273+00:00", "profile": "default"}
{"type": "connection", "event": "connect", "ts": "2026-04-04T01:12:09.162378+00:00", "profile": "default"}
{"type": "connection", "event": "hello", "details": {"version": "1.0"}, "ts": "2026-04-04T01:12:09.163899+00:00", "profile": "default"}
{"type": "connection", "event": "disconnect", "ts": "2026-04-04T01:15:12.826042+00:00", "profile": "default"}
{"type": "connection", "event": "connect", "ts": "2026-04-04T01:15:30.842533+00:00", "profile": "default"}
{"type": "connection", "event": "hello", "details": {"version": "1.0"}, "ts": "2026-04-04T01:15:30.845025+00:00", "profile": "default"}
{"type": "tool_call", "tool": "browser_stop", "params": {"profile": "gcu-browser-worker:3"}, "result": {"ok": true, "status": "not_running", "profile": "gcu-browser-worker:3"}, "ok": true, "duration_ms": 0.01, "ts": "2026-04-04T01:29:04.294954+00:00", "profile": "default"}
-3
View File
@@ -1,3 +0,0 @@
{
"mcpServers": {}
}
+19 -8
View File
@@ -333,6 +333,22 @@ make test-live # Run live API integration tests (requires credentials)
- **WebSocket** for real-time updates
- **Tailwind CSS** for styling
### Frontend Dev Workflow
> **Note:** `./quickstart.sh` handles the full setup including the web UI.
> The commands below are for contributors iterating on the frontend code after
> initial setup is complete.
```bash
# Start the backend server
hive serve
# In a separate terminal, run the frontend dev server with hot-reload
cd core/frontend
npm install # only needed after dependency changes
npm run dev
```
### Useful Development Commands
```bash
@@ -603,11 +619,6 @@ from litellm import completion_cost
cost = completion_cost(model="claude-3-5-sonnet-20241022", messages=[...])
```
**Monitoring Dashboard** (`/core/framework/monitoring/`)
- WebSocket-based real-time monitoring
- Displays: active agents, tool calls, token usage, errors
- Access at: `http://localhost:8000/monitor`
### How to Add Performance Metrics
**1. Instrument your code**
@@ -948,7 +959,7 @@ uv run pytest -m "not live"
**Unit Test**
```python
import pytest
from framework.graph.node import Node
from framework.orchestrator import NodeSpec as Node
def test_node_creation():
node = Node(id="test", name="Test Node", node_type="event_loop")
@@ -966,8 +977,8 @@ async def test_node_execution():
**Integration Test**
```python
import pytest
from framework.graph.executor import GraphExecutor
from framework.graph.node import Node
from framework.orchestrator.orchestrator import Orchestrator as GraphExecutor
from framework.orchestrator import NodeSpec as Node
@pytest.mark.asyncio
async def test_graph_execution_with_multiple_nodes():
+2 -2
View File
@@ -28,7 +28,7 @@ check: ## Run all checks without modifying files (CI-safe)
cd tools && uv run ruff format --check .
test: ## Run all tests (core + tools, excludes live)
cd core && uv run python -m pytest tests/ -v
cd core && uv run python -m pytest tests/ -v --ignore=tests/dummy_agents
cd tools && uv run python -m pytest -v
test-tools: ## Run tool tests only (mocked, no credentials needed)
@@ -38,7 +38,7 @@ test-live: ## Run live integration tests (requires real API credentials)
cd tools && uv run python -m pytest -m live -s -o "addopts=" --log-cli-level=INFO
test-all: ## Run everything including live tests
cd core && uv run python -m pytest tests/ -v
cd core && uv run python -m pytest tests/ -v --ignore=tests/dummy_agents
cd tools && uv run python -m pytest -v
cd tools && uv run python -m pytest -m live -s -o "addopts=" --log-cli-level=INFO
+31 -163
View File
@@ -1,5 +1,5 @@
<p align="center">
<img width="100%" alt="Hive Banner" src="https://github.com/user-attachments/assets/a027429b-5d3c-4d34-88e4-0feaeaabbab3" />
<img width="100%" alt="Hive Banner" src="https://asset.acho.io/github/img/banner.gif" />
</p>
<p align="center">
@@ -23,6 +23,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Agent_Harness-Runtime_Layer-ff6600?style=flat-square" alt="Agent Harness" />
<img src="https://img.shields.io/badge/AI_Agents-Self--Improving-brightgreen?style=flat-square" alt="AI Agents" />
<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" />
@@ -35,39 +36,51 @@
<img src="https://img.shields.io/badge/Google_Gemini-supported-4285F4?style=flat-square&logo=google" alt="Gemini" />
</p>
<p align="center"><em>The agent harness for production workloads — state management, failure recovery, observability, and human oversight so your agents actually run.</em></p>
## Overview
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.
OpenHive is a zero-setup, model-agnostic execution harness that dynamically generates multi-agent topologies to tackle complex, long-running business workflows without requiring any orchestration boilerplate. By simply defining your objective, the runtime compiles a strict, graph-based execution DAG that safely coordinates specialized agents to execute concurrent tasks in parallel. Backed by persistent, role-based memory that intelligently evolves with your project's context, OpenHive ensures deterministic fault tolerance, deep state observability, and seamless asynchronous execution across whichever underlying LLMs you choose to plug in.
## Features
- ✅ Multi-Agent Coordination for parallel task execution
- ✅ Graph-based execution for recurring and complex processes
- ✅ Role-based memory that evolves with your projects
- ✅ Zero Setup - No technical configuration required
- ✅ General Compute Use and Browser Use with Native Extension
- ✅ Custom Model Support
Visit [adenhq.com](https://adenhq.com) for complete documentation, examples, and guides.
Visit [HoneyComb](http://honeycomb.open-hive.com/) to see what jobs are being automated by AI. Its a stock market for jobs, driven by our communitys AI agent progress. You can long and short jobs (with no real money but compute token)based on how much you think a job is going to be replaced by AI.
https://github.com/user-attachments/assets/bf10edc3-06ba-48b6-98ba-d069b15fb69d
## Who Is Hive For?
Hive is designed for developers and teams who want to build many **autonomous AI agents** fast without manually wiring complex workflows.
Hive is the multi-agent harness layer for teams moving AI agents from prototype to production. Single agents like Openclaw and Cowork can finish personal jobs pretty well but lack the rigor to fulfil business processes.
Hive is a good fit if you:
- Want AI agents that **execute real business processes**, not demos
- Need **fast or high volume agent execution** over open workflow
- Need a **runtime that handles state, recovery, and parallel execution** at scale
- Need **self-healing and adaptive agents** that improve over time
- Require **human-in-the-loop control**, observability, and cost limits
- Plan to run agents in **production environments**
- Plan to run agents in **production** where uptime, cost, and auditability matter
Hive may not be the best fit if youre only experimenting with simple agent chains or one-off scripts.
## When Should You Use Hive?
Use Hive when you need:
Use Hive when the bottleneck is no longer the model but the harness around it:
- Long-running, autonomous agents
- Strong guardrails, process, and controls
- Continuous improvement based on failures
- Multi-agent coordination
- A framework that evolves with your goals
- Long-running agents that need **state persistence and crash recovery**
- Production workloads requiring **cost enforcement, observability, and audit trails**
- Agents that **self-heal** through failure capture and graph evolution
- Multi-agent coordination with **session isolation and shared buffers**
- A framework that **scales with model improvements** rather than fighting them
## Quick Links
@@ -100,9 +113,11 @@ Use Hive when you need:
git clone https://github.com/aden-hive/hive.git
cd hive
# Run quickstart setup
# Run quickstart setup (macOS/Linux)
./quickstart.sh
# Windows (PowerShell)
.\quickstart.ps1
```
This sets up:
@@ -133,17 +148,6 @@ Now you can run an agent by selecting the agent (either an existing agent or exa
<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
- **Browser-Use** - Control the browser on your computer to achieve hard tasks
- **Parallel Execution** - Execute the generated graph in parallel. This way you can have multiple agents completing the jobs for you
- **[Goal-Driven Generation](docs/key_concepts/goals_outcome.md)** - Define objectives in natural language; the coding agent generates the agent graph and connection code to achieve them
- **[Adaptiveness](docs/key_concepts/evolution.md)** - Framework captures failures, calibrates according to the objectives, and evolves the agent graph
- **[Dynamic Node Connections](docs/key_concepts/graph.md)** - No predefined edges; connection code is generated by any capable LLM based on your goals
- **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
## Integration
<a href="https://github.com/aden-hive/hive/tree/main/tools/src/aden_tools/tools"><img width="100%" alt="Integration" src="https://github.com/user-attachments/assets/a1573f93-cf02-4bb8-b3d5-b305b05b1e51" /></a>
@@ -152,9 +156,9 @@ Hive is built to be model-agnostic and system-agnostic.
- **LLM flexibility** - Hive Framework supports Anthropic, OpenAI, OpenRouter, Hive LLM, and other hosted or local models through LiteLLM-compatible providers.
- **Business system connectivity** - Hive Framework is designed to connect to all kinds of business systems as tools, such as CRM, support, messaging, data, file, and internal APIs via MCP.
## Why Aden
## Why Hive
Hive focuses on generating agents that run real business processes rather than generic agents. Instead of requiring you to manually design workflows, define agent interactions, and handle failures reactively, Hive flips the paradigm: **you describe outcomes, and the system builds itself**—delivering an outcome-driven, adaptive experience with an easy-to-use set of tools and integrations.
As models improve, the upper bound of what agents can do rises — but their reliability and production value are determined by the harness. Hive focuses on generating agents that run real business processes rather than generic agents. Instead of requiring you to manually design workflows, define agent interactions, and handle failures reactively, Hive flips the paradigm: **you describe outcomes, and the system builds itself**—delivering an outcome-driven, adaptive experience with an easy-to-use set of tools and integrations.
```mermaid
flowchart LR
@@ -188,17 +192,6 @@ flowchart LR
style V6 fill:#fff,stroke:#ed8c00,stroke-width:1px,color:#cc5d00
```
### The Hive Advantage
| Traditional Frameworks | Hive |
| -------------------------- | -------------------------------------- |
| Hardcode agent workflows | Describe goals in natural language |
| Manual graph definition | Auto-generated agent graphs |
| Reactive error handling | Outcome-evaluation and adaptiveness |
| Static tool configurations | Dynamic SDK-wrapped nodes |
| Separate monitoring setup | Built-in real-time observability |
| DIY budget management | Integrated cost controls & degradation |
### How It Works
1. **[Define Your Goal](docs/key_concepts/goals_outcome.md)** → Describe what you want to achieve in plain English
@@ -214,131 +207,6 @@ flowchart LR
- [Configuration Guide](docs/configuration.md) - All configuration options
- [Architecture Overview](docs/architecture/README.md) - System design and structure
## Roadmap
Aden Hive Agent Framework aims to help developers build outcome-oriented, self-adaptive agents. See [roadmap.md](docs/roadmap.md) for details.
```mermaid
flowchart TB
%% Main Entity
User([User])
%% =========================================
%% EXTERNAL EVENT SOURCES
%% =========================================
subgraph ExtEventSource [External Event Source]
E_Sch["Schedulers"]
E_WH["Webhook"]
E_SSE["SSE"]
end
%% =========================================
%% SYSTEM NODES
%% =========================================
subgraph WorkerBees [Worker Bees]
WB_C["Conversation"]
WB_SP["System prompt"]
subgraph Graph [Graph]
direction TB
N1["Node"] --> N2["Node"] --> N3["Node"]
N1 -.-> AN["Active Node"]
N2 -.-> AN
N3 -.-> AN
%% Nested Event Loop Node
subgraph EventLoopNode [Event Loop Node]
ELN_L["listener"]
ELN_SP["System Prompt<br/>(Task)"]
ELN_EL["Event loop"]
ELN_C["Conversation"]
end
end
end
subgraph JudgeNode [Judge]
J_C["Criteria"]
J_P["Principles"]
J_EL["Event loop"] <--> J_S["Scheduler"]
end
subgraph QueenBee [Queen Bee]
QB_SP["System prompt"]
QB_EL["Event loop"]
QB_C["Conversation"]
end
subgraph Infra [Infra]
SA["Sub Agent"]
TR["Tool Registry"]
WTM["Write through Conversation Memory<br/>(Logs/RAM/Harddrive)"]
SM["Shared Memory<br/>(State/Harddrive)"]
EB["Event Bus<br/>(RAM)"]
CS["Credential Store<br/>(Harddrive/Cloud)"]
end
subgraph PC [PC]
B["Browser"]
CB["Codebase<br/>v 0.0.x ... v n.n.n"]
end
%% =========================================
%% CONNECTIONS & DATA FLOW
%% =========================================
%% External Event Routing
E_Sch --> ELN_L
E_WH --> ELN_L
E_SSE --> ELN_L
ELN_L -->|"triggers"| ELN_EL
%% User Interactions
User -->|"Talk"| WB_C
User -->|"Talk"| QB_C
User -->|"Read/Write Access"| CS
%% Inter-System Logic
ELN_C <-->|"Mirror"| WB_C
WB_C -->|"Focus"| AN
WorkerBees -->|"Inquire"| JudgeNode
JudgeNode -->|"Approve"| WorkerBees
%% Judge Alignments
J_C <-.->|"aligns"| WB_SP
J_P <-.->|"aligns"| QB_SP
%% Escalate path
J_EL -->|"Report (Escalate)"| QB_EL
%% Pub/Sub Logic
AN -->|"publish"| EB
EB -->|"subscribe"| QB_C
%% Infra and Process Spawning
ELN_EL -->|"Spawn"| SA
SA -->|"Inform"| ELN_EL
SA -->|"Starts"| B
B -->|"Report"| ELN_EL
TR -->|"Assigned"| ELN_EL
CB -->|"Modify Worker Bee"| WB_C
%% =========================================
%% SHARED MEMORY & LOGS ACCESS
%% =========================================
%% Worker Bees Access (link to node inside Graph subgraph)
AN <-->|"Read/Write"| WTM
AN <-->|"Read/Write"| SM
%% Queen Bee Access
QB_C <-->|"Read/Write"| WTM
QB_EL <-->|"Read/Write"| SM
%% Credentials Access
CS -->|"Read Access"| QB_C
```
## Contributing
We welcome contributions from the community! Were especially looking for help building tools, integrations, and example agents for the framework ([check #2805](https://github.com/aden-hive/hive/issues/2805)). If youre interested in extending its functionality, this is the perfect place to start. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
@@ -385,7 +253,7 @@ Yes! Hive supports local models through LiteLLM. Simply use the model name forma
**Q: What makes Hive different from other agent frameworks?**
Hive generates your entire agent system from natural language goals using a coding agent—you don't hardcode workflows or manually define graphs. When agents fail, the framework automatically captures failure data, [evolves the agent graph](docs/key_concepts/evolution.md), and redeploys. This self-improving loop is unique to Aden.
Hive is an agent harness, not just an orchestration framework. It provides the production runtime layer — session isolation, checkpoint-based crash recovery, cost enforcement, real-time observability, and human-in-the-loop controls — that makes agents reliable enough to run real workloads. On top of that, Hive generates your entire agent system from natural language goals and automatically [evolves the graph](docs/key_concepts/evolution.md) when agents fail. The combination of a robust harness with self-improving generation is what sets Hive apart.
**Q: Is Hive open-source?**
+88 -3
View File
@@ -6,7 +6,7 @@ This guide explains how to integrate Model Context Protocol (MCP) servers with t
The framework provides built-in support for MCP servers, allowing you to:
- **Register MCP servers** via STDIO or HTTP transport
- **Register MCP servers** via STDIO, HTTP, Unix socket, or SSE transport
- **Auto-discover tools** from registered servers
- **Use MCP tools** seamlessly in your agents
- **Manage multiple MCP servers** simultaneously
@@ -104,6 +104,48 @@ runner.register_mcp_server(
- `url`: Base URL of the MCP server
- `headers`: HTTP headers to include (optional)
### Unix Socket Transport
Best for same-host inter-process communication with lower overhead than TCP:
```python
runner.register_mcp_server(
name="local-ipc-tools",
transport="unix",
url="http://localhost",
socket_path="/tmp/mcp_server.sock",
headers={
"Authorization": "Bearer token"
}
)
```
**Configuration:**
- `url`: Base URL for HTTP requests over the socket (required, e.g., `"http://localhost"`)
- `socket_path`: Absolute path to the Unix socket file (required, e.g., `"/tmp/mcp_server.sock"`)
- `headers`: HTTP headers to include (optional)
### SSE Transport
Best for real-time, event-driven connections using the MCP SDK's SSE client:
```python
runner.register_mcp_server(
name="streaming-tools",
transport="sse",
url="http://localhost:8000/sse",
headers={
"Authorization": "Bearer token"
}
)
```
**Configuration:**
- `url`: SSE endpoint URL (required, e.g., `"http://localhost:8000/sse"`)
- `headers`: HTTP headers for the SSE connection (optional)
## Using MCP Tools in Agents
Once registered, MCP tools are available just like any other tool:
@@ -258,7 +300,32 @@ runner.register_mcp_server(
)
```
### 3. Handle Cleanup
### 3. Use Unix Socket for Same-Host IPC
When both the agent and MCP server run on the same machine, Unix sockets avoid TCP overhead:
```python
runner.register_mcp_server(
name="fast-local-tools",
transport="unix",
url="http://localhost",
socket_path="/tmp/mcp_server.sock"
)
```
### 4. Use SSE for Streaming and Real-Time Tools
SSE transport maintains a persistent connection, ideal for event-driven servers:
```python
runner.register_mcp_server(
name="realtime-tools",
transport="sse",
url="http://realtime-server:8000/sse"
)
```
### 5. Handle Cleanup
Always clean up MCP connections when done:
@@ -280,7 +347,7 @@ async with AgentRunner.load("exports/my-agent") as runner:
# Automatic cleanup
```
### 4. Tool Name Conflicts
### 6. Tool Name Conflicts
If multiple MCP servers provide tools with the same name, the last registered server wins. To avoid conflicts:
@@ -315,6 +382,24 @@ If HTTP transport fails:
2. Check firewall settings
3. Verify the URL and port are correct
### Unix Socket Not Connecting
If Unix socket transport fails:
1. Verify the socket file exists: `ls -la /tmp/mcp_server.sock`
2. Check file permissions on the socket
3. Ensure no other process has locked the socket
4. Verify the `url` field is set (e.g., `"http://localhost"`)
### SSE Connection Issues
If SSE transport fails:
1. Verify the server supports SSE at the given URL
2. Check that the `mcp` Python package is installed (`pip install mcp`)
3. Ensure the SSE endpoint is accessible: `curl http://localhost:8000/sse`
4. Check for firewall or proxy issues blocking long-lived connections
## Example: Full Agent with MCP Tools
Here's a complete example of an agent that uses MCP tools:
+7 -21
View File
@@ -52,9 +52,7 @@ _DEFAULT_REDIRECT_PORT = 51121
# This project reverse-engineered and published the public OAuth credentials
# for Google's Antigravity/Cloud Code Assist API.
# Source: https://github.com/NoeFabris/opencode-antigravity-auth
_CREDENTIALS_URL = (
"https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/dev/src/constants.ts"
)
_CREDENTIALS_URL = "https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/dev/src/constants.ts"
# Cached credentials fetched from public source
_cached_client_id: str | None = None
@@ -68,9 +66,7 @@ def _fetch_credentials_from_public_source() -> tuple[str | None, str | None]:
return _cached_client_id, _cached_client_secret
try:
req = urllib.request.Request(
_CREDENTIALS_URL, headers={"User-Agent": "Hive-Antigravity-Auth/1.0"}
)
req = urllib.request.Request(_CREDENTIALS_URL, headers={"User-Agent": "Hive-Antigravity-Auth/1.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
content = resp.read().decode("utf-8")
import re
@@ -168,10 +164,7 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
if "code" in query and "state" in query:
OAuthCallbackHandler.auth_code = query["code"][0]
OAuthCallbackHandler.state = query["state"][0]
self._send_response(
"Authentication successful! You can close this window "
"and return to the terminal."
)
self._send_response("Authentication successful! You can close this window and return to the terminal.")
return
self._send_response("Waiting for authentication...")
@@ -296,8 +289,7 @@ def validate_credentials(access_token: str, project_id: str = _DEFAULT_PROJECT_I
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/1.18.3"
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/1.18.3"
),
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
}
@@ -316,9 +308,7 @@ def validate_credentials(access_token: str, project_id: str = _DEFAULT_PROJECT_I
return False
def refresh_access_token(
refresh_token: str, client_id: str, client_secret: str | None
) -> dict | None:
def refresh_access_token(refresh_token: str, client_id: str, client_secret: str | None) -> dict | None:
"""Refresh the access token using the refresh token."""
data = {
"grant_type": "refresh_token",
@@ -361,9 +351,7 @@ def cmd_account_add(args: argparse.Namespace) -> int:
access_token = account.get("access")
refresh_token_str = account.get("refresh", "")
refresh_token = refresh_token_str.split("|")[0] if refresh_token_str else None
project_id = (
refresh_token_str.split("|")[1] if "|" in refresh_token_str else _DEFAULT_PROJECT_ID
)
project_id = refresh_token_str.split("|")[1] if "|" in refresh_token_str else _DEFAULT_PROJECT_ID
email = account.get("email", "unknown")
expires_ms = account.get("expires", 0)
expires_at = expires_ms / 1000.0 if expires_ms else 0.0
@@ -390,9 +378,7 @@ def cmd_account_add(args: argparse.Namespace) -> int:
# Update the account
account["access"] = new_access
account["expires"] = int((time.time() + expires_in) * 1000)
accounts_data["last_refresh"] = time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime()
)
accounts_data["last_refresh"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
save_accounts(accounts_data)
# Validate the refreshed token
-132
View File
@@ -1,132 +0,0 @@
"""
Minimal Manual Agent Example
----------------------------
This example demonstrates how to build and run an agent programmatically
without using the Claude Code CLI or external LLM APIs.
It uses custom NodeProtocol implementations to define logic in pure Python,
making it perfect for understanding the core runtime loop:
Setup -> Graph definition -> Execution -> Result
Run with:
uv run python core/examples/manual_agent.py
"""
import asyncio
from framework.graph import EdgeCondition, EdgeSpec, Goal, GraphSpec, NodeSpec
from framework.graph.executor import GraphExecutor
from framework.graph.node import NodeContext, NodeProtocol, NodeResult
from framework.runtime.core import Runtime
# 1. Define Node Logic (Custom NodeProtocol implementations)
class GreeterNode(NodeProtocol):
"""Generate a simple greeting."""
async def execute(self, ctx: NodeContext) -> NodeResult:
name = ctx.input_data.get("name", "World")
greeting = f"Hello, {name}!"
ctx.memory.write("greeting", greeting)
return NodeResult(success=True, output={"greeting": greeting})
class UppercaserNode(NodeProtocol):
"""Convert text to uppercase."""
async def execute(self, ctx: NodeContext) -> NodeResult:
greeting = ctx.input_data.get("greeting") or ctx.memory.read("greeting") or ""
result = greeting.upper()
ctx.memory.write("final_greeting", result)
return NodeResult(success=True, output={"final_greeting": result})
async def main():
print("Setting up Manual Agent...")
# 2. Define the Goal
# Every agent needs a goal with success criteria
goal = Goal(
id="greet-user",
name="Greet User",
description="Generate a friendly uppercase greeting",
success_criteria=[
{
"id": "greeting_generated",
"description": "Greeting produced",
"metric": "custom",
"target": "any",
}
],
)
# 3. Define Nodes
# Nodes describe steps in the process
node1 = NodeSpec(
id="greeter",
name="Greeter",
description="Generates a simple greeting",
node_type="event_loop",
input_keys=["name"],
output_keys=["greeting"],
)
node2 = NodeSpec(
id="uppercaser",
name="Uppercaser",
description="Converts greeting to uppercase",
node_type="event_loop",
input_keys=["greeting"],
output_keys=["final_greeting"],
)
# 4. Define Edges
# Edges define the flow between nodes
edge1 = EdgeSpec(
id="greet-to-upper",
source="greeter",
target="uppercaser",
condition=EdgeCondition.ON_SUCCESS,
)
# 5. Create Graph
# The graph works like a blueprint connecting nodes and edges
graph = GraphSpec(
id="greeting-agent",
goal_id="greet-user",
entry_node="greeter",
terminal_nodes=["uppercaser"],
nodes=[node1, node2],
edges=[edge1],
)
# 6. Initialize Runtime & Executor
# Runtime handles state/memory; Executor runs the graph
from pathlib import Path
runtime = Runtime(storage_path=Path("./agent_logs"))
executor = GraphExecutor(runtime=runtime)
# 7. Register Node Implementations
# Connect node IDs in the graph to actual Python implementations
executor.register_node("greeter", GreeterNode())
executor.register_node("uppercaser", UppercaserNode())
# 8. Execute Agent
print("Executing agent with input: name='Alice'...")
result = await executor.execute(graph=graph, goal=goal, input_data={"name": "Alice"})
# 9. Verify Results
if result.success:
print("\nSuccess!")
print(f"Path taken: {' -> '.join(result.path)}")
print(f"Final output: {result.output.get('final_greeting')}")
else:
print(f"\nFailed: {result.error}")
if __name__ == "__main__":
# Optional: Enable logging to see internal decision flow
# logging.basicConfig(level=logging.INFO)
asyncio.run(main())
-119
View File
@@ -1,119 +0,0 @@
#!/usr/bin/env python3
"""
Example: Integrating MCP Servers with the Core Framework
This example demonstrates how to:
1. Register MCP servers programmatically
2. Use MCP tools in agents
3. Load MCP servers from configuration files
"""
import asyncio
from pathlib import Path
from framework.runner.runner import AgentRunner
async def example_1_programmatic_registration():
"""Example 1: Register MCP server programmatically"""
print("\n=== Example 1: Programmatic MCP Server Registration ===\n")
# Load an existing agent
runner = AgentRunner.load("exports/task-planner")
# Register tools MCP server via STDIO
num_tools = runner.register_mcp_server(
name="tools",
transport="stdio",
command="python",
args=["-m", "aden_tools.mcp_server", "--stdio"],
cwd="../tools",
)
print(f"Registered {num_tools} tools from tools MCP server")
# List all available tools
tools = runner._tool_registry.get_tools()
print(f"\nAvailable tools: {list(tools.keys())}")
# Run the agent with MCP tools available
result = await runner.run(
{"objective": "Search for 'Claude AI' and summarize the top 3 results"}
)
print(f"\nAgent result: {result}")
# Cleanup
runner.cleanup()
async def example_2_http_transport():
"""Example 2: Connect to MCP server via HTTP"""
print("\n=== Example 2: HTTP MCP Server Connection ===\n")
# First, start the tools MCP server in HTTP mode:
# cd tools && python mcp_server.py --port 4001
runner = AgentRunner.load("exports/task-planner")
# Register tools via HTTP
num_tools = runner.register_mcp_server(
name="tools-http",
transport="http",
url="http://localhost:4001",
)
print(f"Registered {num_tools} tools from HTTP MCP server")
# Cleanup
runner.cleanup()
async def example_3_config_file():
"""Example 3: Load MCP servers from configuration file"""
print("\n=== Example 3: Load from Configuration File ===\n")
# Create a test agent folder with mcp_servers.json
test_agent_path = Path("exports/task-planner")
# Copy example config (in practice, you'd place this in your agent folder)
import shutil
shutil.copy(Path(__file__).parent / "mcp_servers.json", test_agent_path / "mcp_servers.json")
# Load agent - MCP servers will be auto-discovered
runner = AgentRunner.load(test_agent_path)
# Tools are automatically available
tools = runner._tool_registry.get_tools()
print(f"Available tools: {list(tools.keys())}")
# Cleanup
runner.cleanup()
# Clean up the test config
(test_agent_path / "mcp_servers.json").unlink()
async def main():
"""Run all examples"""
print("=" * 60)
print("MCP Integration Examples")
print("=" * 60)
try:
# Run examples
await example_1_programmatic_registration()
# await example_2_http_transport() # Requires HTTP server running
# await example_3_config_file()
# await example_4_custom_agent_with_mcp_tools()
except Exception as e:
print(f"\nError running example: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())
+14 -61
View File
@@ -1,67 +1,20 @@
"""
Aden Hive Framework: A goal-driven agent runtime optimized for Builder observability.
"""Hive Agent Framework.
The runtime is designed around DECISIONS, not just actions. Every significant
choice the agent makes is captured with:
- What it was trying to do (intent)
- What options it considered
- What it chose and why
- What happened as a result
- Whether that was good or bad (evaluated post-hoc)
This gives the Builder LLM the information it needs to improve agent behavior.
## Testing Framework
The framework includes a Goal-Based Testing system (Goal Agent Eval):
- Generate tests from Goal success_criteria and constraints
- Mandatory user approval before tests are stored
- Parallel test execution with error categorization
- Debug tools with fix suggestions
See `framework.testing` for details.
Core classes:
ColonyRuntime -- orchestrates parallel worker clones in a colony
AgentLoop -- the LLM + tool execution loop (one per worker)
AgentLoader -- loads agent config from disk, builds pipeline
DecisionTracker -- records decisions for post-hoc analysis
"""
from framework.llm import AnthropicProvider, LLMProvider
from framework.runner import AgentOrchestrator, AgentRunner
from framework.runtime.core import Runtime
from framework.schemas.decision import Decision, DecisionEvaluation, Option, Outcome
from framework.schemas.run import Problem, Run, RunSummary
# Testing framework
from framework.testing import (
ApprovalStatus,
DebugTool,
ErrorCategory,
Test,
TestResult,
TestStorage,
TestSuiteResult,
)
from framework.agent_loop import AgentLoop
from framework.host import ColonyRuntime
from framework.loader import AgentLoader
from framework.tracker import DecisionTracker
__all__ = [
# Schemas
"Decision",
"Option",
"Outcome",
"DecisionEvaluation",
"Run",
"RunSummary",
"Problem",
# Runtime
"Runtime",
# LLM
"LLMProvider",
"AnthropicProvider",
# Runner
"AgentRunner",
"AgentOrchestrator",
# Testing
"Test",
"TestResult",
"TestSuiteResult",
"TestStorage",
"ApprovalStatus",
"ErrorCategory",
"DebugTool",
"ColonyRuntime",
"AgentLoader",
"AgentLoop",
"DecisionTracker",
]
+34
View File
@@ -0,0 +1,34 @@
"""Agent loop -- the core agent execution primitive."""
from framework.agent_loop.conversation import ( # noqa: F401
ConversationStore,
Message,
NodeConversation,
)
from framework.agent_loop.types import ( # noqa: F401
AgentContext,
AgentProtocol,
AgentResult,
AgentSpec,
)
def __getattr__(name: str):
if name in ("AgentLoop", "JudgeProtocol", "JudgeVerdict", "LoopConfig", "OutputAccumulator"):
from framework.agent_loop.agent_loop import (
AgentLoop,
JudgeProtocol,
JudgeVerdict,
LoopConfig,
OutputAccumulator,
)
_exports = {
"AgentLoop": AgentLoop,
"JudgeProtocol": JudgeProtocol,
"JudgeVerdict": JudgeVerdict,
"LoopConfig": LoopConfig,
"OutputAccumulator": OutputAccumulator,
}
return _exports[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
"""Agent loop internals -- compaction, judge, tools, subagent execution.
Re-exports from legacy locations for the new import path.
"""
from framework.agent_loop.internals.compaction import * # noqa: F401, F403
from framework.agent_loop.internals.synthetic_tools import * # noqa: F401, F403
@@ -0,0 +1,884 @@
"""Conversation compaction pipeline.
Implements the multi-level compaction strategy:
0. Microcompaction (count-based tool result clearing cheapest)
1. Prune old tool results (token-budget based)
2. Structure-preserving compaction (spillover)
3. LLM summary compaction (with recursive splitting)
4. Emergency deterministic summary (no LLM)
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from framework.agent_loop.conversation import Message, NodeConversation
from framework.agent_loop.internals.event_publishing import publish_context_usage
from framework.agent_loop.internals.types import LoopConfig, OutputAccumulator
from framework.host.event_bus import EventBus
from framework.orchestrator.node import NodeContext
logger = logging.getLogger(__name__)
# Limits for LLM compaction
LLM_COMPACT_CHAR_LIMIT: int = 240_000
LLM_COMPACT_MAX_DEPTH: int = 10
# Microcompaction: tools whose results can be safely cleared
COMPACTABLE_TOOLS: frozenset[str] = frozenset(
{
"read_file",
"run_command",
"web_search",
"web_fetch",
"grep_search",
"glob_search",
"write_file",
"edit_file",
"browser_screenshot",
"list_directory",
}
)
# Keep at most this many compactable tool results; clear older ones
MICROCOMPACT_KEEP_RECENT: int = 8
# Circuit-breaker: stop auto-compacting after this many consecutive failures
MAX_CONSECUTIVE_FAILURES: int = 3
# Track consecutive compaction failures per conversation (module-level)
_failure_counts: dict[int, int] = {}
# Track last compaction time per conversation for recompaction detection
_last_compact_times: dict[int, float] = {}
def microcompact(
conversation: NodeConversation,
*,
keep_recent: int = MICROCOMPACT_KEEP_RECENT,
) -> int:
"""Clear old compactable tool results by count, keeping only the most recent.
This is the cheapest possible compaction no LLM call, no structural
changes, just replaces old tool result content with a short placeholder.
Inspired by Claude Code's cached-microcompact strategy.
Returns the number of tool results cleared.
"""
# Collect indices of compactable tool results (newest first)
compactable_indices: list[int] = []
messages = conversation.messages
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if msg.role != "tool" or msg.is_error or msg.is_skill_content:
continue
if msg.content.startswith(("Pruned tool result", "[Pruned tool result", "[Old tool result")):
continue
if len(msg.content) < 100:
continue
# Check if the tool that produced this result is compactable
tool_name = _find_tool_name_for_result(messages, msg)
if tool_name and tool_name in COMPACTABLE_TOOLS:
compactable_indices.append(i)
# Keep the most recent N, clear the rest
to_clear = compactable_indices[keep_recent:]
if not to_clear:
return 0
cleared = 0
for i in to_clear:
msg = messages[i]
spillover = _extract_spillover_filename_inline(msg.content)
orig_len = len(msg.content)
if spillover:
placeholder = (
f"Old tool result ({orig_len:,} chars) cleared from context. "
f"Full data saved at: {spillover}\n"
f"Read the complete data with read_file(path='{spillover}')."
)
else:
placeholder = f"Old tool result ({orig_len:,} chars) cleared from context."
# Mutate in-place (microcompact is synchronous, no store writes)
conversation._messages[i] = Message(
seq=msg.seq,
role=msg.role,
content=placeholder,
tool_use_id=msg.tool_use_id,
tool_calls=msg.tool_calls,
is_error=msg.is_error,
phase_id=msg.phase_id,
is_transition_marker=msg.is_transition_marker,
)
cleared += 1
if cleared > 0:
# Invalidate cached token count
conversation._last_api_input_tokens = None
return cleared
def _find_tool_name_for_result(messages: list[Message], tool_msg: Message) -> str | None:
"""Find the tool name from the assistant message that triggered this tool result."""
if not tool_msg.tool_use_id:
return None
for msg in messages:
if msg.tool_calls:
for tc in msg.tool_calls:
if tc.get("id") == tool_msg.tool_use_id:
return tc.get("function", {}).get("name")
return None
def _extract_spillover_filename_inline(content: str) -> str | None:
"""Quick inline check for spillover filename in tool result content.
Matches both the new prose format ("saved at: /path") and the
legacy bracketed trailer ("saved to '/path'").
"""
match = re.search(r"saved at:\s*(\S+)", content, re.IGNORECASE)
if match:
return match.group(1)
match = re.search(r"saved to '([^']+)'", content, re.IGNORECASE)
return match.group(1) if match else None
async def compact(
ctx: NodeContext,
conversation: NodeConversation,
accumulator: OutputAccumulator | None,
*,
config: LoopConfig,
event_bus: EventBus | None,
char_limit: int = LLM_COMPACT_CHAR_LIMIT,
max_depth: int = LLM_COMPACT_MAX_DEPTH,
) -> None:
"""Run the full compaction pipeline if conversation needs compaction.
Pipeline stages (in order, short-circuits when budget is restored):
0. Microcompaction (count-based tool result clearing cheapest)
1. Prune old tool results (token-budget based)
2. Structure-preserving compaction (free, no LLM)
3. LLM summary compaction (recursive split if too large)
4. Emergency deterministic summary (fallback)
"""
conv_id = id(conversation)
# Circuit breaker: stop LLM-based compaction after repeated failures,
# but still fall through to the emergency deterministic summary so
# the conversation doesn't silently grow past the context window.
# Without this, a persistent LLM outage during compaction would
# leave the agent stuck sending oversized prompts until the API 400s.
_llm_compaction_skipped = _failure_counts.get(conv_id, 0) >= MAX_CONSECUTIVE_FAILURES
if _llm_compaction_skipped:
logger.warning(
"Circuit breaker: LLM compaction disabled after %d failures — skipping straight to emergency summary",
_failure_counts[conv_id],
)
# Recompaction detection
now = time.monotonic()
last_time = _last_compact_times.get(conv_id)
if last_time is not None and (now - last_time) < 30:
logger.warning(
"Recompaction chain detected: only %.1fs since last compaction",
now - last_time,
)
ratio_before = conversation.usage_ratio()
phase_grad = getattr(ctx, "continuous_mode", False)
pre_inventory: list[dict[str, Any]] | None = None
if ratio_before >= 1.0:
pre_inventory = build_message_inventory(conversation)
# --- Step 0: Microcompaction (count-based, cheapest) ---
mc_cleared = microcompact(conversation)
if mc_cleared > 0:
logger.info(
"Microcompact cleared %d old tool results: %.0f%% -> %.0f%%",
mc_cleared,
ratio_before * 100,
conversation.usage_ratio() * 100,
)
if not conversation.needs_compaction():
_record_success(conv_id, now)
await log_compaction(
ctx,
conversation,
ratio_before,
event_bus,
pre_inventory=pre_inventory,
)
return
# --- Step 1: Prune old tool results (free, fast) ---
protect = max(2000, config.max_context_tokens // 12)
pruned = await conversation.prune_old_tool_results(
protect_tokens=protect,
min_prune_tokens=max(1000, protect // 3),
)
if pruned > 0:
logger.info(
"Pruned %d old tool results: %.0f%% -> %.0f%%",
pruned,
ratio_before * 100,
conversation.usage_ratio() * 100,
)
if not conversation.needs_compaction():
_record_success(conv_id, now)
await log_compaction(
ctx,
conversation,
ratio_before,
event_bus,
pre_inventory=pre_inventory,
)
return
# --- Step 2: Standard structure-preserving compaction (free, no LLM) ---
spill_dir = config.spillover_dir
if spill_dir:
await conversation.compact_preserving_structure(
spillover_dir=spill_dir,
keep_recent=4,
phase_graduated=phase_grad,
)
if not conversation.needs_compaction():
_record_success(conv_id, now)
await log_compaction(
ctx,
conversation,
ratio_before,
event_bus,
pre_inventory=pre_inventory,
)
return
# --- Step 3: LLM summary compaction ---
if ctx.llm is not None and not _llm_compaction_skipped:
logger.info(
"LLM summary compaction triggered (%.0f%% usage)",
conversation.usage_ratio() * 100,
)
try:
summary = await llm_compact(
ctx,
list(conversation.messages),
accumulator,
char_limit=char_limit,
max_depth=max_depth,
max_context_tokens=config.max_context_tokens,
)
await conversation.compact(
summary,
keep_recent=2,
phase_graduated=phase_grad,
)
except Exception as e:
logger.warning("LLM compaction failed: %s", e)
_failure_counts[conv_id] = _failure_counts.get(conv_id, 0) + 1
if not conversation.needs_compaction():
_record_success(conv_id, now)
await log_compaction(
ctx,
conversation,
ratio_before,
event_bus,
pre_inventory=pre_inventory,
)
return
# --- Step 4: Emergency deterministic summary (LLM failed/unavailable) ---
logger.warning(
"Emergency compaction (%.0f%% usage)",
conversation.usage_ratio() * 100,
)
summary = build_emergency_summary(ctx, accumulator, conversation, config)
await conversation.compact(
summary,
keep_recent=1,
phase_graduated=phase_grad,
)
_record_success(conv_id, now)
await log_compaction(
ctx,
conversation,
ratio_before,
event_bus,
pre_inventory=pre_inventory,
)
def _record_success(conv_id: int, timestamp: float) -> None:
"""Reset failure counter and record compaction time on success."""
_failure_counts.pop(conv_id, None)
_last_compact_times[conv_id] = timestamp
# --- LLM compaction with binary-search splitting ----------------------
def strip_images_from_messages(messages: list[Message]) -> list[Message]:
"""Strip image_content from messages before LLM summarisation.
Images/documents are replaced with ``[image]`` markers so the summary
notes they existed without wasting tokens sending binary data to the
compaction LLM. Returns a new list (original messages are not mutated).
"""
stripped: list[Message] = []
for msg in messages:
if msg.image_content:
n_images = len(msg.image_content)
marker = " ".join("[image]" for _ in range(n_images))
content = f"{msg.content}\n{marker}" if msg.content else marker
stripped.append(
Message(
seq=msg.seq,
role=msg.role,
content=content,
tool_use_id=msg.tool_use_id,
tool_calls=msg.tool_calls,
is_error=msg.is_error,
phase_id=msg.phase_id,
is_transition_marker=msg.is_transition_marker,
image_content=None, # stripped
)
)
else:
stripped.append(msg)
return stripped
async def llm_compact(
ctx: NodeContext,
messages: list,
accumulator: OutputAccumulator | None = None,
_depth: int = 0,
*,
char_limit: int = LLM_COMPACT_CHAR_LIMIT,
max_depth: int = LLM_COMPACT_MAX_DEPTH,
max_context_tokens: int = 128_000,
preserve_user_messages: bool = False,
) -> str:
"""Summarise *messages* with LLM, splitting recursively if too large.
If the formatted text exceeds ``LLM_COMPACT_CHAR_LIMIT`` or the LLM
rejects the call with a context-length error, the messages are split
in half and each half is summarised independently. Tool history is
appended once at the top-level call (``_depth == 0``).
When ``preserve_user_messages`` is True, the prompt and system message
are amplified to instruct the LLM to keep every user message verbatim
and in full used by the manual /compact-and-fork endpoint where the
user wants their voice carried into the new session intact.
"""
from framework.agent_loop.conversation import extract_tool_call_history
from framework.agent_loop.internals.tool_result_handler import is_context_too_large_error
if _depth > max_depth:
raise RuntimeError(f"LLM compaction recursion limit ({max_depth})")
# Strip images before summarisation to avoid wasting tokens
if _depth == 0:
messages = strip_images_from_messages(messages)
formatted = format_messages_for_summary(messages)
# Proactive split: avoid wasting an API call on oversized input
if len(formatted) > char_limit and len(messages) > 1:
summary = await _llm_compact_split(
ctx,
messages,
accumulator,
_depth,
char_limit=char_limit,
max_depth=max_depth,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
else:
prompt = build_llm_compaction_prompt(
ctx,
accumulator,
formatted,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
if preserve_user_messages:
system_msg = (
"You are a conversation compactor for an AI agent. "
"Write a detailed summary that allows the agent to "
"continue its work. CRITICAL: reproduce every user "
"message verbatim and in full inside the 'User Messages' "
"section — do not paraphrase, truncate, or merge them. "
"Assistant turns and tool results may be summarised, but "
"user input is sacred."
)
else:
system_msg = (
"You are a conversation compactor for an AI agent. "
"Write a detailed summary that allows the agent to "
"continue its work. Preserve user-stated rules, "
"constraints, and account/identity preferences verbatim."
)
summary_budget = max(1024, max_context_tokens // 2)
try:
response = await ctx.llm.acomplete(
messages=[{"role": "user", "content": prompt}],
system=system_msg,
max_tokens=summary_budget,
)
summary = response.content
except Exception as e:
if is_context_too_large_error(e) and len(messages) > 1:
logger.info(
"LLM context too large (depth=%d, msgs=%d) — splitting",
_depth,
len(messages),
)
summary = await _llm_compact_split(
ctx,
messages,
accumulator,
_depth,
char_limit=char_limit,
max_depth=max_depth,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
else:
raise
# Append tool history at top level only
if _depth == 0:
tool_history = extract_tool_call_history(messages)
if tool_history and "TOOLS ALREADY CALLED" not in summary:
summary += "\n\n" + tool_history
return summary
async def _llm_compact_split(
ctx: NodeContext,
messages: list,
accumulator: OutputAccumulator | None,
_depth: int,
*,
char_limit: int = LLM_COMPACT_CHAR_LIMIT,
max_depth: int = LLM_COMPACT_MAX_DEPTH,
max_context_tokens: int = 128_000,
preserve_user_messages: bool = False,
) -> str:
"""Split messages in half and summarise each half independently."""
mid = max(1, len(messages) // 2)
s1 = await llm_compact(
ctx,
messages[:mid],
None,
_depth + 1,
char_limit=char_limit,
max_depth=max_depth,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
s2 = await llm_compact(
ctx,
messages[mid:],
accumulator,
_depth + 1,
char_limit=char_limit,
max_depth=max_depth,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
return s1 + "\n\n" + s2
# --- Compaction helpers ------------------------------------------------
def format_messages_for_summary(messages: list) -> str:
"""Format messages as text for LLM summarisation."""
lines: list[str] = []
for m in messages:
if m.role == "tool":
content = m.content[:500]
if len(m.content) > 500:
content += "..."
lines.append(f"[tool result]: {content}")
elif m.role == "assistant" and m.tool_calls:
names = [tc.get("function", {}).get("name", "?") for tc in m.tool_calls]
text = m.content[:200] if m.content else ""
lines.append(f"[assistant (calls: {', '.join(names)})]: {text}")
else:
lines.append(f"[{m.role}]: {m.content}")
return "\n\n".join(lines)
def build_llm_compaction_prompt(
ctx: NodeContext,
accumulator: OutputAccumulator | None,
formatted_messages: str,
*,
max_context_tokens: int = 128_000,
preserve_user_messages: bool = False,
) -> str:
"""Build prompt for LLM compaction targeting 50% of token budget.
Uses a structured section format inspired by Claude Code's compact
service. Each section focuses on a different aspect of the conversation
so the summariser produces consistently useful, well-organised output.
"""
spec = ctx.agent_spec
ctx_lines = [f"NODE: {spec.name} (id={spec.id})"]
if spec.description:
ctx_lines.append(f"PURPOSE: {spec.description}")
if spec.success_criteria:
ctx_lines.append(f"SUCCESS CRITERIA: {spec.success_criteria}")
if accumulator:
acc = accumulator.to_dict()
done = {k: v for k, v in acc.items() if v is not None}
todo = [k for k, v in acc.items() if v is None]
if done:
ctx_lines.append("OUTPUTS ALREADY SET:\n" + "\n".join(f" {k}: {str(v)[:150]}" for k, v in done.items()))
if todo:
ctx_lines.append(f"OUTPUTS STILL NEEDED: {', '.join(todo)}")
elif spec.output_keys:
ctx_lines.append(f"OUTPUTS STILL NEEDED: {', '.join(spec.output_keys)}")
target_tokens = max_context_tokens // 2
target_chars = target_tokens * 4
node_ctx = "\n".join(ctx_lines)
user_messages_section = (
"6. **User Messages** — Reproduce EVERY user message verbatim and "
"in full, in chronological order, each on its own line prefixed "
'with the message index (e.g. "[U1] ..."). Do NOT paraphrase, '
"summarise, merge, or omit any user message. Preserve markdown, "
"code fences, whitespace, and punctuation exactly as the user "
"wrote them.\n"
if preserve_user_messages
else "6. **User Messages** — Preserve ALL user-stated rules, constraints, "
"identity preferences, and account details verbatim.\n"
)
return (
"You are compacting an AI agent's conversation history. "
"The agent is still working and needs to continue.\n\n"
f"AGENT CONTEXT:\n{node_ctx}\n\n"
f"CONVERSATION MESSAGES:\n{formatted_messages}\n\n"
"INSTRUCTIONS:\n"
f"Write a summary of approximately {target_chars} characters "
f"(~{target_tokens} tokens).\n\n"
"Organise the summary into these sections (omit empty ones):\n\n"
"1. **Primary Request and Intent** — What the user originally asked "
"for and the high-level goal the agent is working toward.\n"
"2. **Key Technical Concepts** — Important domain-specific terms, "
"patterns, or architectural decisions established in the conversation.\n"
"3. **Files and Code Sections** — Specific files read/written/edited "
"with brief descriptions of changes. Include short code snippets only "
"when they capture critical logic.\n"
"4. **Errors and Fixes** — Problems encountered and how they were "
"resolved. Include root causes so the agent doesn't repeat them.\n"
"5. **Problem Solving Efforts** — Approaches tried, dead ends hit, "
"and reasoning behind the current strategy.\n"
f"{user_messages_section}"
"7. **Pending Tasks** — Work remaining, outputs still needed, and "
"any blockers.\n"
"8. **Current Work** — The most recent action taken and the immediate "
"next step the agent should perform. This section is the most important "
"for seamless resumption.\n\n"
"Additional rules:\n"
"- Be detailed enough that the agent can resume without re-doing work.\n"
"- Preserve key decisions made and results obtained.\n"
"- When in doubt, keep information rather than discard it.\n"
)
def build_message_inventory(conversation: NodeConversation) -> list[dict[str, Any]]:
"""Build a per-message size inventory for debug logging."""
inventory: list[dict[str, Any]] = []
for message in conversation.messages:
content_chars = len(message.content)
tool_call_args_chars = 0
tool_name = None
if message.tool_calls:
for tool_call in message.tool_calls:
args = tool_call.get("function", {}).get("arguments", "")
tool_call_args_chars += len(args) if isinstance(args, str) else len(json.dumps(args))
names = [tool_call.get("function", {}).get("name", "?") for tool_call in message.tool_calls]
tool_name = ", ".join(names)
elif message.role == "tool" and message.tool_use_id:
for previous in conversation.messages:
if previous.tool_calls:
for tool_call in previous.tool_calls:
if tool_call.get("id") == message.tool_use_id:
tool_name = tool_call.get("function", {}).get("name", "?")
break
if tool_name:
break
entry: dict[str, Any] = {
"seq": message.seq,
"role": message.role,
"content_chars": content_chars,
}
if tool_call_args_chars:
entry["tool_call_args_chars"] = tool_call_args_chars
if tool_name:
entry["tool"] = tool_name
if message.is_error:
entry["is_error"] = True
if message.phase_id:
entry["phase"] = message.phase_id
if content_chars > 2000:
entry["preview"] = message.content[:200] + ""
inventory.append(entry)
return inventory
def write_compaction_debug_log(
ctx: NodeContext,
before_pct: int,
after_pct: int,
level: str,
inventory: list[dict[str, Any]] | None,
) -> None:
"""Write detailed compaction analysis to ~/.hive/compaction_log/."""
log_dir = Path.home() / ".hive" / "compaction_log"
log_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%S_%f")
node_label = ctx.agent_id.replace("/", "_")
log_path = log_dir / f"{ts}_{node_label}.md"
lines: list[str] = [
f"# Compaction Debug — {ctx.agent_id}",
f"**Time:** {datetime.now(UTC).isoformat()}",
f"**Node:** {ctx.agent_spec.name} (`{ctx.agent_id}`)",
]
if ctx.stream_id:
lines.append(f"**Stream:** {ctx.stream_id}")
lines.append(f"**Level:** {level}")
lines.append(f"**Usage:** {before_pct}% → {after_pct}%")
lines.append("")
if inventory:
total_chars = sum(entry.get("content_chars", 0) + entry.get("tool_call_args_chars", 0) for entry in inventory)
lines.append(f"## Pre-Compaction Message Inventory ({len(inventory)} messages, {total_chars:,} total chars)")
lines.append("")
ranked = sorted(
inventory,
key=lambda entry: entry.get("content_chars", 0) + entry.get("tool_call_args_chars", 0),
reverse=True,
)
lines.append("| # | seq | role | tool | chars | % of total | flags |")
lines.append("|---|-----|------|------|------:|------------|-------|")
for i, entry in enumerate(ranked, 1):
chars = entry.get("content_chars", 0) + entry.get("tool_call_args_chars", 0)
pct = (chars / total_chars * 100) if total_chars else 0
tool = entry.get("tool", "")
flags: list[str] = []
if entry.get("is_error"):
flags.append("error")
if entry.get("phase"):
flags.append(f"phase={entry['phase']}")
lines.append(
f"| {i} | {entry['seq']} | {entry['role']} | {tool} | {chars:,} | {pct:.1f}% | {', '.join(flags)} |"
)
large = [entry for entry in ranked if entry.get("preview")]
if large:
lines.append("")
lines.append("### Large message previews")
for entry in large:
lines.append(f"\n**seq={entry['seq']}** ({entry['role']}, {entry.get('tool', '')}):")
lines.append(f"```\n{entry['preview']}\n```")
lines.append("")
try:
log_path.write_text("\n".join(lines), encoding="utf-8")
logger.debug("Compaction debug log written to %s", log_path)
except OSError:
logger.debug("Failed to write compaction debug log to %s", log_path)
async def log_compaction(
ctx: NodeContext,
conversation: NodeConversation,
ratio_before: float,
event_bus: EventBus | None,
*,
pre_inventory: list[dict[str, Any]] | None = None,
) -> None:
"""Log compaction result to runtime logger and event bus."""
ratio_after = conversation.usage_ratio()
before_pct = round(ratio_before * 100)
after_pct = round(ratio_after * 100)
# Determine label from what happened
if after_pct >= before_pct - 1:
level = "prune_only"
elif ratio_after <= 0.6:
level = "llm"
else:
level = "structural"
logger.info(
"Compaction complete (%s): %d%% -> %d%%",
level,
before_pct,
after_pct,
)
if ctx.runtime_logger:
ctx.runtime_logger.log_step(
node_id=ctx.agent_id,
node_type="event_loop",
step_index=-1,
llm_text=f"Context compacted ({level}): {before_pct}% \u2192 {after_pct}%",
verdict="COMPACTION",
verdict_feedback=f"level={level} before={before_pct}% after={after_pct}%",
)
if event_bus:
from framework.host.event_bus import AgentEvent, EventType
event_data: dict[str, Any] = {
"level": level,
"usage_before": before_pct,
"usage_after": after_pct,
}
if pre_inventory is not None:
event_data["message_inventory"] = pre_inventory
await event_bus.publish(
AgentEvent(
type=EventType.CONTEXT_COMPACTED,
stream_id=ctx.stream_id or ctx.agent_id,
node_id=ctx.agent_id,
data=event_data,
)
)
await publish_context_usage(event_bus, ctx, conversation, "post_compaction")
if os.environ.get("HIVE_COMPACTION_DEBUG"):
write_compaction_debug_log(ctx, before_pct, after_pct, level, pre_inventory)
def build_emergency_summary(
ctx: NodeContext,
accumulator: OutputAccumulator | None = None,
conversation: NodeConversation | None = None,
config: LoopConfig | None = None,
) -> str:
"""Build a structured emergency compaction summary.
Unlike normal/aggressive compaction which uses an LLM summary,
emergency compaction cannot afford an LLM call (context is already
way over budget). Instead, build a deterministic summary from the
node's known state so the LLM can continue working after
compaction without losing track of its task and inputs.
"""
parts = ["EMERGENCY COMPACTION — previous conversation was too large and has been replaced with this summary.\n"]
# 1. Node identity
spec = ctx.agent_spec
parts.append(f"NODE: {spec.name} (id={spec.id})")
if spec.description:
parts.append(f"PURPOSE: {spec.description}")
# 2. Inputs the node received
input_lines = []
for key in spec.input_keys:
value = ctx.input_data.get(key)
if value is not None:
# Truncate long values but keep them recognisable
v_str = str(value)
if len(v_str) > 200:
v_str = v_str[:200] + ""
input_lines.append(f" {key}: {v_str}")
if input_lines:
parts.append("INPUTS:\n" + "\n".join(input_lines))
# 3. Output accumulator state (what's been set so far)
if accumulator:
acc_state = accumulator.to_dict()
set_keys = {k: v for k, v in acc_state.items() if v is not None}
missing = [k for k, v in acc_state.items() if v is None]
if set_keys:
lines = [f" {k}: {str(v)[:150]}" for k, v in set_keys.items()]
parts.append("OUTPUTS ALREADY SET:\n" + "\n".join(lines))
if missing:
parts.append(f"OUTPUTS STILL NEEDED: {', '.join(missing)}")
elif spec.output_keys:
parts.append(f"OUTPUTS STILL NEEDED: {', '.join(spec.output_keys)}")
# 4. Available tools reminder
if spec.tools:
parts.append(f"AVAILABLE TOOLS: {', '.join(spec.tools)}")
# 5. Spillover files — list actual files so the LLM can load
# them immediately instead of having to call list_data_files first.
spillover_dir = config.spillover_dir if config else None
if spillover_dir:
try:
from pathlib import Path
data_dir = Path(spillover_dir)
if data_dir.is_dir():
all_files = sorted(f.name for f in data_dir.iterdir() if f.is_file())
# Separate conversation history files from regular data files
conv_files = [f for f in all_files if re.match(r"conversation_\d+\.md$", f)]
data_files = [f for f in all_files if f not in conv_files]
if conv_files:
conv_list = "\n".join(f" - {f} (full path: {data_dir / f})" for f in conv_files)
parts.append(
"CONVERSATION HISTORY (freeform messages saved during compaction — "
"use read_file('<filename>') to review earlier dialogue):\n" + conv_list
)
if data_files:
file_list = "\n".join(f" - {f} (full path: {data_dir / f})" for f in data_files[:30])
parts.append("DATA FILES (use read_file('<filename>') to read):\n" + file_list)
if not all_files:
parts.append(
"NOTE: Large tool results may have been saved to files. "
"Use list_directory to check the data directory."
)
except Exception:
parts.append("NOTE: Large tool results were saved to files. Use read_file(path='<path>') to read them.")
# 6. Tool call history (prevent re-calling tools)
if conversation is not None:
tool_history = _extract_tool_call_history(conversation)
if tool_history:
parts.append(tool_history)
parts.append("\nContinue working towards setting the remaining outputs. Use your tools and the inputs above.")
return "\n\n".join(parts)
def _extract_tool_call_history(conversation: NodeConversation) -> str:
"""Extract tool call history from conversation messages.
This is the instance-level variant that operates on a NodeConversation
directly (vs. the module-level extract_tool_call_history in conversation.py
which works on raw message lists).
"""
from framework.agent_loop.conversation import extract_tool_call_history
return extract_tool_call_history(list(conversation.messages))
@@ -0,0 +1,267 @@
"""Cursor persistence, queue draining, and pause detection.
Handles the checkpoint/resume cycle: restoring state from a previous
conversation store, writing cursor data, and managing injection/trigger
queues between iterations.
"""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from framework.agent_loop.conversation import ConversationStore, NodeConversation
from framework.agent_loop.internals.types import LoopConfig, OutputAccumulator, TriggerEvent
from framework.llm.capabilities import supports_image_tool_results
from framework.orchestrator.node import NodeContext
logger = logging.getLogger(__name__)
@dataclass
class RestoredState:
"""State recovered from a previous checkpoint."""
conversation: NodeConversation
accumulator: OutputAccumulator
start_iteration: int
recent_responses: list[str]
recent_tool_fingerprints: list[list[tuple[str, str]]]
pending_input: dict[str, Any] | None
async def restore(
conversation_store: ConversationStore | None,
ctx: NodeContext,
config: LoopConfig,
) -> RestoredState | None:
"""Attempt to restore from a previous checkpoint.
Returns a ``RestoredState`` with conversation, accumulator, iteration
counter, and stall/doom-loop detection state everything needed to
resume exactly where execution stopped.
"""
if conversation_store is None:
return None
# In isolated mode, filter parts by phase_id so the node only sees
# its own messages in the shared flat conversation store. In
# continuous mode (or when _restore is called for timer-resume)
# load all parts — the full conversation threads across nodes.
_is_continuous = getattr(ctx, "continuous_mode", False)
# The queen has agent_id="queen" but messages are stored with phase_id=None.
# Only apply phase filtering for non-queen workers in a multi-agent setup.
phase_filter = None if (_is_continuous or ctx.agent_id == "queen") else ctx.agent_id
conversation = await NodeConversation.restore(
conversation_store,
phase_id=phase_filter,
run_id=ctx.effective_run_id,
)
if conversation is None:
logger.info(
"[restore] No conversation found for agent_id=%s phase_filter=%s run_id=%s",
ctx.agent_id,
phase_filter,
ctx.effective_run_id,
)
return None
logger.info(
"[restore] Restored %d messages for agent_id=%s phase_filter=%s run_id=%s",
conversation.message_count,
ctx.agent_id,
phase_filter,
ctx.effective_run_id,
)
# If run_id filtering removed all messages, this is an intentional
# restart (new run), not a crash recovery. Return None so the caller
# falls through to the fresh-conversation path.
if conversation.message_count == 0:
return None
accumulator = await OutputAccumulator.restore(conversation_store, run_id=ctx.effective_run_id)
accumulator.spillover_dir = config.spillover_dir
accumulator.max_value_chars = config.max_output_value_chars
cursor = await conversation_store.read_cursor() or {}
start_iteration = cursor.get("iteration", 0) + 1
# Restore stall/doom-loop detection state
recent_responses: list[str] = cursor.get("recent_responses", [])
raw_fps = cursor.get("recent_tool_fingerprints", [])
recent_tool_fingerprints: list[list[tuple[str, str]]] = [
[tuple(pair) for pair in fps] # type: ignore[misc]
for fps in raw_fps
]
pending_input = cursor.get("pending_input")
if not isinstance(pending_input, dict):
pending_input = None
logger.info(
f"Restored event loop: iteration={start_iteration}, "
f"messages={conversation.message_count}, "
f"outputs={list(accumulator.values.keys())}, "
f"stall_window={len(recent_responses)}, "
f"doom_window={len(recent_tool_fingerprints)}"
)
return RestoredState(
conversation=conversation,
accumulator=accumulator,
start_iteration=start_iteration,
recent_responses=recent_responses,
recent_tool_fingerprints=recent_tool_fingerprints,
pending_input=pending_input,
)
async def write_cursor(
conversation_store: ConversationStore | None,
ctx: NodeContext,
conversation: NodeConversation,
accumulator: OutputAccumulator,
iteration: int,
*,
recent_responses: list[str] | None = None,
recent_tool_fingerprints: list[list[tuple[str, str]]] | None = None,
pending_input: dict[str, Any] | None = None,
) -> None:
"""Write checkpoint cursor for crash recovery.
Persists iteration counter, accumulator outputs, and stall/doom-loop
detection state so that resume picks up exactly where execution stopped.
"""
if conversation_store:
cursor = await conversation_store.read_cursor() or {}
cursor.update(
{
"iteration": iteration,
"node_id": ctx.agent_id,
"outputs": accumulator.to_dict(),
}
)
# Persist stall/doom-loop detection state for reliable resume
if recent_responses is not None:
cursor["recent_responses"] = recent_responses
if recent_tool_fingerprints is not None:
# Convert list[list[tuple]] → list[list[list]] for JSON
cursor["recent_tool_fingerprints"] = [[list(pair) for pair in fps] for fps in recent_tool_fingerprints]
# Persist blocked-input state so restored runs re-block instead of
# manufacturing a synthetic continuation turn.
cursor["pending_input"] = pending_input
await conversation_store.write_cursor(cursor)
async def drain_injection_queue(
queue: asyncio.Queue,
conversation: NodeConversation,
*,
ctx: NodeContext,
describe_images_as_text_fn: (Callable[[list[dict[str, Any]]], Awaitable[str | None]] | None) = None,
) -> int:
"""Drain all pending injected events as user messages. Returns count."""
count = 0
logger.debug(
"[drain_injection_queue] Starting to drain queue, initial queue size: %s",
queue.qsize() if hasattr(queue, "qsize") else "unknown",
)
while not queue.empty():
try:
content, is_client_input, image_content = queue.get_nowait()
logger.info(
"[drain] injected message (client_input=%s, images=%d): %s",
is_client_input,
len(image_content) if image_content else 0,
content[:200] if content else "(empty)",
)
if image_content and ctx.llm and not supports_image_tool_results(ctx.llm.model):
logger.info(
"Model '%s' does not support images; attempting vision fallback",
ctx.llm.model,
)
if describe_images_as_text_fn is not None:
description = await describe_images_as_text_fn(image_content)
if description:
content = f"{content}\n\n{description}" if content else description
logger.info("[drain] image described as text via vision fallback")
else:
logger.info("[drain] no vision fallback available; images dropped")
image_content = None
# Real user input is stored as-is; external events get a prefix
if is_client_input:
await conversation.add_user_message(
content,
is_client_input=True,
image_content=image_content,
)
else:
await conversation.add_user_message(f"[External event]: {content}")
count += 1
except asyncio.QueueEmpty:
break
return count
async def drain_trigger_queue(
queue: asyncio.Queue,
conversation: NodeConversation,
) -> int:
"""Drain all pending trigger events as a single batched user message.
Multiple triggers are merged so the LLM sees them atomically and can
reason about all pending triggers before acting.
"""
triggers: list[TriggerEvent] = []
while not queue.empty():
try:
triggers.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
if not triggers:
return 0
parts: list[str] = []
for t in triggers:
task = t.payload.get("task", "")
task_line = f"\nTask: {task}" if task else ""
payload_str = json.dumps(t.payload, default=str)
parts.append(f"[TRIGGER: {t.trigger_type}/{t.source_id}]{task_line}\n{payload_str}")
combined = "\n\n".join(parts)
logger.info("[drain] %d trigger(s): %s", len(triggers), combined[:200])
# Tag the message so the UI can render a banner instead of the raw
# `[TRIGGER: ...]` text. The LLM still sees `combined` verbatim.
await conversation.add_user_message(combined, is_trigger=True)
return len(triggers)
async def check_pause(
ctx: NodeContext,
conversation: NodeConversation,
iteration: int,
) -> bool:
"""
Check if pause has been requested. Returns True if paused.
Note: This check happens BEFORE starting iteration N, after completing N-1.
If paused, the node exits having completed {iteration} iterations (0 to iteration-1).
"""
# Check executor-level pause event (for /pause command, Ctrl+Z)
if ctx.pause_event and ctx.pause_event.is_set():
completed = iteration # 0-indexed: iteration=3 means 3 iterations completed (0,1,2)
logger.info(f"⏸ Pausing after {completed} iteration(s) completed (executor-level)")
return True
# Check context-level pause flags (legacy/alternative methods)
pause_requested = ctx.input_data.get("pause_requested", False)
if pause_requested:
completed = iteration
logger.info(f"⏸ Pausing after {completed} iteration(s) completed (context-level)")
return True
return False
@@ -0,0 +1,358 @@
"""EventBus publishing helpers for the event loop.
Thin wrappers around EventBus.emit_*() calls that check for bus existence
before publishing. Extracted to reduce noise in the main orchestrator.
"""
from __future__ import annotations
import logging
import time
from framework.agent_loop.conversation import NodeConversation
from framework.agent_loop.internals.types import HookContext
from framework.host.event_bus import EventBus
from framework.orchestrator.node import NodeContext
logger = logging.getLogger(__name__)
async def publish_loop_started(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
max_iterations: int,
execution_id: str = "",
) -> None:
if event_bus:
await event_bus.emit_node_loop_started(
stream_id=stream_id,
node_id=node_id,
max_iterations=max_iterations,
execution_id=execution_id,
)
async def generate_action_plan(
event_bus: EventBus | None,
ctx: NodeContext,
stream_id: str,
node_id: str,
execution_id: str,
) -> None:
"""Generate a brief action plan via LLM and emit it as an SSE event.
Runs as a fire-and-forget task so it never blocks the main loop.
"""
try:
system_prompt = ctx.agent_spec.system_prompt or ""
# Trim to keep the prompt small
prompt_summary = system_prompt[:500]
if len(system_prompt) > 500:
prompt_summary += "..."
tool_names = [t.name for t in ctx.available_tools]
output_keys = ctx.agent_spec.output_keys or []
prompt = (
f'You are about to work on a task as node "{node_id}".\n\n'
f"System prompt:\n{prompt_summary}\n\n"
f"Tools available: {tool_names}\n"
f"Required outputs: {output_keys}\n\n"
f"Write a brief action plan (2-5 bullet points) describing "
f"what you will do to complete this task. Be specific and concise.\n"
f"Return ONLY the plan text, no preamble."
)
response = await ctx.llm.acomplete(
messages=[{"role": "user", "content": prompt}],
max_tokens=1024,
)
plan = response.content.strip()
if plan and event_bus:
await event_bus.emit_node_action_plan(
stream_id=stream_id,
node_id=node_id,
plan=plan,
execution_id=execution_id,
)
except Exception as e:
logger.warning("Action plan generation failed for node '%s': %s", node_id, e)
async def publish_iteration(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
iteration: int,
execution_id: str = "",
extra_data: dict | None = None,
) -> None:
if event_bus:
await event_bus.emit_node_loop_iteration(
stream_id=stream_id,
node_id=node_id,
iteration=iteration,
execution_id=execution_id,
extra_data=extra_data,
)
async def publish_llm_turn_complete(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
stop_reason: str,
model: str,
input_tokens: int,
output_tokens: int,
cached_tokens: int = 0,
execution_id: str = "",
iteration: int | None = None,
) -> None:
if event_bus:
await event_bus.emit_llm_turn_complete(
stream_id=stream_id,
node_id=node_id,
stop_reason=stop_reason,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
execution_id=execution_id,
iteration=iteration,
)
def log_skip_judge(
ctx: NodeContext,
node_id: str,
iteration: int,
feedback: str,
tool_calls: list[dict],
llm_text: str,
turn_tokens: dict[str, int],
iter_start: float,
) -> None:
"""Log a CONTINUE step that skips judge evaluation (e.g., waiting for input)."""
if ctx.runtime_logger:
ctx.runtime_logger.log_step(
node_id=node_id,
node_type="event_loop",
step_index=iteration,
verdict="CONTINUE",
verdict_feedback=feedback,
tool_calls=tool_calls,
llm_text=llm_text,
input_tokens=turn_tokens.get("input", 0),
output_tokens=turn_tokens.get("output", 0),
latency_ms=int((time.time() - iter_start) * 1000),
)
async def publish_loop_completed(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
iterations: int,
execution_id: str = "",
) -> None:
if event_bus:
await event_bus.emit_node_loop_completed(
stream_id=stream_id,
node_id=node_id,
iterations=iterations,
execution_id=execution_id,
)
async def publish_context_usage(
event_bus: EventBus | None,
ctx: NodeContext,
conversation: NodeConversation,
trigger: str,
) -> None:
"""Emit a CONTEXT_USAGE_UPDATED event with current context window state."""
if not event_bus:
return
from framework.host.event_bus import AgentEvent, EventType
estimated = conversation.estimate_tokens()
max_tokens = conversation._max_context_tokens
ratio = estimated / max_tokens if max_tokens > 0 else 0.0
await event_bus.publish(
AgentEvent(
type=EventType.CONTEXT_USAGE_UPDATED,
stream_id=ctx.stream_id or ctx.agent_id,
node_id=ctx.agent_id,
data={
"usage_ratio": round(ratio, 4),
"usage_pct": round(ratio * 100),
"message_count": conversation.message_count,
"estimated_tokens": estimated,
"max_context_tokens": max_tokens,
"trigger": trigger,
},
)
)
async def publish_stalled(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
execution_id: str = "",
) -> None:
if event_bus:
await event_bus.emit_node_stalled(
stream_id=stream_id,
node_id=node_id,
reason="Consecutive similar responses detected",
execution_id=execution_id,
)
async def publish_text_delta(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
content: str,
snapshot: str,
ctx: NodeContext,
execution_id: str = "",
iteration: int | None = None,
inner_turn: int = 0,
) -> None:
if event_bus:
if ctx.emits_client_io:
await event_bus.emit_client_output_delta(
stream_id=stream_id,
node_id=node_id,
content=content,
snapshot=snapshot,
execution_id=execution_id,
iteration=iteration,
inner_turn=inner_turn,
)
else:
await event_bus.emit_llm_text_delta(
stream_id=stream_id,
node_id=node_id,
content=content,
snapshot=snapshot,
execution_id=execution_id,
inner_turn=inner_turn,
)
async def publish_tool_started(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
tool_use_id: str,
tool_name: str,
tool_input: dict,
execution_id: str = "",
) -> None:
if event_bus:
await event_bus.emit_tool_call_started(
stream_id=stream_id,
node_id=node_id,
tool_use_id=tool_use_id,
tool_name=tool_name,
tool_input=tool_input,
execution_id=execution_id,
)
async def publish_tool_completed(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
tool_use_id: str,
tool_name: str,
result: str,
is_error: bool,
execution_id: str = "",
) -> None:
if event_bus:
await event_bus.emit_tool_call_completed(
stream_id=stream_id,
node_id=node_id,
tool_use_id=tool_use_id,
tool_name=tool_name,
result=result,
is_error=is_error,
execution_id=execution_id,
)
async def publish_judge_verdict(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
action: str,
feedback: str = "",
judge_type: str = "implicit",
iteration: int = 0,
execution_id: str = "",
) -> None:
if event_bus:
await event_bus.emit_judge_verdict(
stream_id=stream_id,
node_id=node_id,
action=action,
feedback=feedback,
judge_type=judge_type,
iteration=iteration,
execution_id=execution_id,
)
async def publish_output_key_set(
event_bus: EventBus | None,
stream_id: str,
node_id: str,
key: str,
execution_id: str = "",
) -> None:
if event_bus:
pass
async def run_hooks(
hooks_config: dict[str, list],
event: str,
conversation: NodeConversation,
trigger: str | None = None,
) -> None:
"""Run all registered hooks for *event*, applying their results.
Each hook receives a HookContext and may return a HookResult that:
- replaces the system prompt (result.system_prompt)
- injects an extra user message (result.inject)
Hooks run in registration order; each sees the prompt as left by the
previous hook.
"""
hook_list = hooks_config.get(event, [])
if not hook_list:
return
for hook in hook_list:
ctx = HookContext(
event=event,
trigger=trigger,
system_prompt=conversation.system_prompt,
)
try:
result = await hook(ctx)
except Exception:
logger.warning("Hook '%s' raised an exception", event, exc_info=True)
continue
if result is None:
continue
if result.system_prompt:
conversation.update_system_prompt(result.system_prompt)
if result.inject:
await conversation.add_user_message(result.inject)
@@ -0,0 +1,152 @@
"""Judge evaluation pipeline for the event loop."""
from __future__ import annotations
import logging
from collections.abc import Callable
from framework.agent_loop.conversation import NodeConversation
from framework.agent_loop.internals.types import JudgeProtocol, JudgeVerdict, OutputAccumulator
from framework.orchestrator.node import NodeContext
logger = logging.getLogger(__name__)
class SubagentJudge:
"""Judge for subagent execution."""
def __init__(self, task: str, max_iterations: int = 10):
self._task = task
self._max_iterations = max_iterations
async def evaluate(self, context: dict[str, object]) -> JudgeVerdict:
missing = context.get("missing_keys", [])
if not isinstance(missing, list) or not missing:
return JudgeVerdict(action="ACCEPT", feedback="")
iteration = context.get("iteration", 0)
if not isinstance(iteration, int):
iteration = 0
remaining = self._max_iterations - iteration - 1
if remaining <= 3:
urgency = (
f"URGENT: Only {remaining} iterations left. Stop all other work and call set_output NOW for: {missing}"
)
elif remaining <= self._max_iterations // 2:
urgency = f"WARNING: {remaining} iterations remaining. You must call set_output for: {missing}"
else:
urgency = f"Missing output keys: {missing}. Use set_output to provide them."
return JudgeVerdict(action="RETRY", feedback=f"Your task: {self._task}\n{urgency}")
async def judge_turn(
*,
mark_complete_flag: bool,
judge: JudgeProtocol | None,
ctx: NodeContext,
conversation: NodeConversation,
accumulator: OutputAccumulator,
assistant_text: str,
tool_results: list[dict[str, object]],
iteration: int,
get_missing_output_keys_fn: Callable[
[OutputAccumulator, list[str] | None, list[str] | None],
list[str],
],
max_context_tokens: int,
) -> JudgeVerdict:
"""Evaluate the current state using judge or implicit logic.
Evaluation levels (in order):
0. Short-circuits: mark_complete, skip_judge, tool-continue.
1. Custom judge (JudgeProtocol) full authority when set.
2. Implicit judge output-key check + optional conversation-aware
quality gate (when ``success_criteria`` is defined).
Returns a JudgeVerdict. ``feedback=None`` means no real evaluation
happened (skip_judge, tool-continue); the caller must not inject a
feedback message. Any non-None feedback (including ``""``) means a
real evaluation occurred and will be logged into the conversation.
"""
# --- Level 0: short-circuits (no evaluation) -----------------------
if mark_complete_flag:
return JudgeVerdict(action="ACCEPT")
if ctx.agent_spec.skip_judge:
return JudgeVerdict(action="RETRY") # feedback=None → not logged
# --- Level 1: custom judge -----------------------------------------
if judge is not None:
context = {
"assistant_text": assistant_text,
"tool_calls": tool_results,
"output_accumulator": accumulator.to_dict(),
"accumulator": accumulator,
"iteration": iteration,
"conversation_summary": conversation.export_summary(),
"output_keys": ctx.agent_spec.output_keys,
"missing_keys": get_missing_output_keys_fn(
accumulator, ctx.agent_spec.output_keys, ctx.agent_spec.nullable_output_keys
),
}
verdict = await judge.evaluate(context)
# Ensure evaluated RETRY always carries feedback for logging.
if verdict.action == "RETRY" and not verdict.feedback:
return JudgeVerdict(action="RETRY", feedback="Custom judge returned RETRY.")
return verdict
# --- Level 2: implicit judge ---------------------------------------
# Real tool calls were made — let the agent keep working.
if tool_results:
return JudgeVerdict(action="RETRY") # feedback=None → not logged
missing = get_missing_output_keys_fn(accumulator, ctx.agent_spec.output_keys, ctx.agent_spec.nullable_output_keys)
if missing:
return JudgeVerdict(
action="RETRY",
feedback=(
f"Task incomplete. Required outputs not yet produced: {missing}. "
f"Follow your system prompt instructions to complete the work."
),
)
# All output keys present — run safety checks before accepting.
output_keys = ctx.agent_spec.output_keys or []
nullable_keys = set(ctx.agent_spec.nullable_output_keys or [])
# All-nullable with nothing set → node produced nothing useful.
all_nullable = output_keys and nullable_keys >= set(output_keys)
none_set = not any(accumulator.get(k) is not None for k in output_keys)
if all_nullable and none_set:
return JudgeVerdict(
action="RETRY",
feedback=(f"No output keys have been set yet. Use set_output to set at least one of: {output_keys}"),
)
# Level 2b: conversation-aware quality check (if success_criteria set)
if ctx.agent_spec.success_criteria and ctx.llm:
from framework.orchestrator.conversation_judge import evaluate_phase_completion
verdict = await evaluate_phase_completion(
llm=ctx.llm,
conversation=conversation,
phase_name=ctx.agent_spec.name,
phase_description=ctx.agent_spec.description,
success_criteria=ctx.agent_spec.success_criteria,
accumulator_state=accumulator.to_dict(),
max_context_tokens=max_context_tokens,
)
if verdict.action != "ACCEPT":
return JudgeVerdict(
action=verdict.action,
feedback=verdict.feedback or "Phase criteria not met.",
)
return JudgeVerdict(action="ACCEPT", feedback="")
@@ -0,0 +1,106 @@
"""Stall and doom-loop detection for the event loop.
Pure functions with no class dependencies safe to call from any context.
"""
from __future__ import annotations
import json
def ngram_similarity(s1: str, s2: str, n: int = 2) -> float:
"""Jaccard similarity of n-gram sets.
Returns 0.0-1.0, where 1.0 is exact match.
Fast: O(len(s) + len(s2)) using set operations.
"""
def _ngrams(s: str) -> set[str]:
return {s[i : i + n] for i in range(len(s) - n + 1) if s.strip()}
if not s1 or not s2:
return 0.0
ngrams1, ngrams2 = _ngrams(s1.lower()), _ngrams(s2.lower())
if not ngrams1 or not ngrams2:
return 0.0
intersection = len(ngrams1 & ngrams2)
union = len(ngrams1 | ngrams2)
return intersection / union if union else 0.0
def is_stalled(
recent_responses: list[str],
threshold: int,
similarity_threshold: float,
) -> bool:
"""Detect stall using n-gram similarity.
Detects when ALL N consecutive responses are mutually similar
(>= threshold). A single dissimilar response resets the signal.
This catches phrases like "I'm still stuck" vs "I'm stuck"
without false-positives on "attempt 1" vs "attempt 2".
"""
if len(recent_responses) < threshold:
return False
if not recent_responses[0]:
return False
# Every consecutive pair must be similar
for i in range(1, len(recent_responses)):
if ngram_similarity(recent_responses[i], recent_responses[i - 1]) < similarity_threshold:
return False
return True
def fingerprint_tool_calls(
tool_results: list[dict],
) -> list[tuple[str, str]]:
"""Create deterministic fingerprints for a turn's tool calls.
Each fingerprint is (tool_name, canonical_args_json). Order-sensitive
so [search("a"), fetch("b")] != [fetch("b"), search("a")].
"""
fingerprints = []
for tr in tool_results:
name = tr.get("tool_name", "")
args = tr.get("tool_input", {})
try:
canonical = json.dumps(args, sort_keys=True, default=str)
except (TypeError, ValueError):
canonical = str(args)
fingerprints.append((name, canonical))
return fingerprints
def is_tool_doom_loop(
recent_tool_fingerprints: list[list[tuple[str, str]]],
threshold: int,
enabled: bool = True,
) -> tuple[bool, str]:
"""Detect doom loop via exact fingerprint match.
Detects when N consecutive turns invoke the same tools with
identical (canonicalized) arguments. Different arguments mean
different work, so only exact matches count.
Returns (is_doom_loop, description).
"""
if not enabled:
return False, ""
if len(recent_tool_fingerprints) < threshold:
return False, ""
first = recent_tool_fingerprints[0]
if not first:
return False, ""
# All turns in the window must match the first exactly
if all(fp == first for fp in recent_tool_fingerprints[1:]):
tool_names = [name for name, _ in first]
desc = (
f"Doom loop detected: {len(recent_tool_fingerprints)} "
f"identical consecutive tool calls ({', '.join(tool_names)})"
)
return True, desc
return False, ""
@@ -0,0 +1,387 @@
"""Synthetic tool builders for the event loop.
Factory functions that create ``Tool`` definitions for framework-level
synthetic tools (set_output, ask_user, escalate, delegate, report_to_parent).
Also includes the ``handle_set_output`` validation logic.
All functions are pure they receive explicit parameters and return
``Tool`` or ``ToolResult`` objects with no side effects.
"""
from __future__ import annotations
from typing import Any
from framework.llm.provider import Tool, ToolResult
def sanitize_ask_user_inputs(
raw_question: Any,
raw_options: Any,
) -> tuple[str, list[str] | None]:
"""Self-heal a malformed ``ask_user`` tool call.
Some model families (notably when the system prompt teaches them
XML-ish scratchpad tags like ``<relationship>...</relationship>``)
carry that style into tool arguments and produce calls like::
ask_user({
"question": "What now?</question>\\n_OPTIONS: [\\"A\\", \\"B\\"]"
})
Symptoms:
- The chat UI renders ``</question>`` and ``_OPTIONS: [...]`` as
literal text in the question bubble.
- No buttons appear because the real ``options`` parameter is
empty.
This function:
- Strips leading/trailing whitespace.
- Removes a trailing ``</question>`` (with optional preceding
whitespace) from the question text.
- Detects an inline ``_OPTIONS:``, ``OPTIONS:``, or ``options:``
line followed by a JSON array, parses it, and returns the
recovered list as the second element.
- Removes the parsed line from the returned question text.
Returns ``(cleaned_question, recovered_options_or_None)``. The
caller should treat the recovered list as a fallback only when
the model did not also supply a real ``options`` array.
"""
import json as _json
import re as _re
if raw_question is None:
return "", None
q = str(raw_question)
# Strip a stray </question> tag (case-insensitive, with optional
# preceding whitespace) anywhere in the string. This is the most
# common failure mode and never represents valid content.
q = _re.sub(r"\s*</\s*question\s*>\s*", "\n", q, flags=_re.IGNORECASE)
# Look for an inline options line. Match _OPTIONS, OPTIONS, options
# (with or without leading underscore), followed by ':' or '=', then
# a JSON array on the same line OR on the next line.
inline_options_re = _re.compile(
r"(?im)^\s*_?options\s*[:=]\s*(\[.*?\])\s*$",
_re.DOTALL,
)
recovered: list[str] | None = None
match = inline_options_re.search(q)
if match is not None:
try:
parsed = _json.loads(match.group(1))
if isinstance(parsed, list):
cleaned = [str(o).strip() for o in parsed if str(o).strip()]
if 1 <= len(cleaned) <= 8:
recovered = cleaned
except (ValueError, TypeError):
pass
if recovered is not None:
# Remove the parsed line so it doesn't leak into the
# rendered question text.
q = inline_options_re.sub("", q, count=1)
# Strip any final whitespace / leftover blank lines from the
# question after removals.
q = _re.sub(r"\n{3,}", "\n\n", q).strip()
return q, recovered
ask_user_prompt = """\
Use this tool when you need to ask the user questions during execution. Reach for it when:
- The task is ambiguous and the user needs to choose an approach
- You need missing information to continue
- You want approval before taking a meaningful action
- A decision has real trade-offs the user should weigh in on
- You want post-task feedback, or to offer saving a skill or updating memory
Usage notes:
- Users will always be able to select "Other" to provide custom text input, \
so do not include catch-all options like "Other" or "Something else" yourself.
- Each option is a plain string. Do NOT wrap options in `{"label": "..."}` or \
`{"value": "..."}` objects pass the raw choice text directly, e.g. `"Email"`, \
not `{"label": "Email"}`.
- If you recommend a specific option, make that the first option in the list \
and append " (Recommended)" to the end of its text.
- Call this tool whenever you need the user's response.
- The prompt field must be plain text only.
- Do not include XML, pseudo-tags, or inline option lists inside prompt.
- Omit options only when the question truly requires a free-form response the \
user must type out, such as describing an idea or pasting an error message.
- Do not repeat the questions in your normal text response. The widget renders \
them, so keep any surrounding text to a brief intro only.
Example single question with options:
{"questions": [{"id": "next", "prompt": "What would you like to do?", \
"options": ["Build a new agent (Recommended)", "Modify existing agent", "Run tests"]}]}
Example batch:
{"questions": [
{"id": "scope", "prompt": "What scope?", "options": ["Full", "Partial"]},
{"id": "format", "prompt": "Output format?", "options": ["PDF", "CSV", "JSON"]},
{"id": "details", "prompt": "Any special requirements?"}
]}
Example free-form (queen only):
{"questions": [{"id": "idea", "prompt": "Describe the agent you want to build."}]}
"""
def build_ask_user_tool() -> Tool:
"""Build the synthetic ask_user tool for explicit user-input requests.
The queen calls ask_user() when it needs to pause and wait for user
input. Accepts an array of 1-8 questions a single question for the
common case, or a batch when several clarifications are needed at once.
Text-only turns WITHOUT ask_user flow through without blocking, allowing
progress updates and summaries to stream freely.
"""
return Tool(
name="ask_user",
description=ask_user_prompt,
parameters={
"type": "object",
"properties": {
"questions": {
"type": "array",
"minItems": 1,
"maxItems": 8,
"description": "List of questions to present to the user.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": ("Short identifier for this question (used in the response)."),
},
"prompt": {
"type": "string",
"description": "The question text shown to the user.",
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": (
"2-3 predefined choices as plain strings "
'(e.g. ["Yes", "No", "Maybe"]). Do NOT '
'wrap items in {"label": "..."} or '
'{"value": "..."} objects — pass the raw '
"choice text directly. The UI appends an "
"'Other' free-text input automatically, "
"so don't include catch-all options. "
"Omit only when the user must type a free-form answer."
),
"minItems": 2,
"maxItems": 3,
},
},
"required": ["id", "prompt"],
},
},
},
"required": ["questions"],
},
)
def build_set_output_tool(output_keys: list[str] | None) -> Tool | None:
"""Build the synthetic set_output tool for explicit output declaration."""
if not output_keys:
return None
return Tool(
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={
"type": "object",
"properties": {
"key": {
"type": "string",
"description": f"Output key. Must be one of: {output_keys}",
"enum": output_keys,
},
"value": {
"type": "string",
"description": ("The output value — a brief note, count, status, or data filename reference."),
},
},
"required": ["key", "value"],
},
)
def build_escalate_tool() -> Tool:
"""Build the synthetic escalate tool for worker -> queen handoff."""
return Tool(
name="escalate",
description=(
"Escalate to the queen when requesting user input, "
"blocked by errors, missing "
"credentials, or ambiguous constraints that require supervisor "
"guidance. Include a concise reason and optional context. "
"The node will pause until the queen injects guidance."
),
parameters={
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": ("Short reason for escalation (e.g. 'Tool repeatedly failing')."),
},
"context": {
"type": "string",
"description": "Optional diagnostic details for the queen.",
},
},
"required": ["reason"],
},
)
def build_report_to_parent_tool() -> Tool:
"""Build the synthetic ``report_to_parent`` tool.
Parallel workers (those spawned by the overseer via
``run_parallel_workers``) call this to send a structured report back
to the overseer queen when they have finished their task. Calling
``report_to_parent`` terminates the worker's loop cleanly -- do not
call other tools after it.
The overseer receives these as ``SUBAGENT_REPORT`` events and
aggregates them into a single summary for the user.
"""
return Tool(
name="report_to_parent",
description=(
"Send a structured report back to the parent overseer and "
"terminate. Call this when you have finished your task "
"(success, partial, or failed) or cannot make further "
"progress. Your loop ends after this call -- do not call any "
"other tool afterwards. The overseer reads the summary + "
"data fields and aggregates them into a user-facing response."
),
parameters={
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["success", "partial", "failed"],
"description": (
"Overall outcome. 'success' = task complete. "
"'partial' = some progress but incomplete. "
"'failed' = could not make progress."
),
},
"summary": {
"type": "string",
"description": (
"One-paragraph narrative for the overseer. What "
"you did, what you found, and any notable issues."
),
},
"data": {
"type": "object",
"description": (
"Optional structured payload (rows fetched, IDs "
"processed, files written, etc.) that the "
"overseer can merge into its final summary."
),
},
},
"required": ["status", "summary"],
},
)
def handle_report_to_parent(tool_input: dict[str, Any]) -> ToolResult:
"""Normalise + validate a ``report_to_parent`` tool call.
Returns a ``ToolResult`` with the acknowledgement text the LLM sees;
the side effects (record on Worker, emit SUBAGENT_REPORT, terminate
loop) are performed by ``AgentLoop`` after this helper returns.
"""
status = str(tool_input.get("status", "success")).strip().lower()
if status not in ("success", "partial", "failed"):
status = "success"
summary = str(tool_input.get("summary", "")).strip()
if not summary:
summary = f"(worker returned {status} with no summary)"
data = tool_input.get("data") or {}
if not isinstance(data, dict):
data = {"value": data}
# Store the normalised payload back on the input dict so the caller
# can pick it up without re-parsing.
tool_input["_normalised"] = {
"status": status,
"summary": summary,
"data": data,
}
return ToolResult(
tool_use_id=tool_input.get("tool_use_id", ""),
content=(f"Report delivered to overseer (status={status}). This worker will terminate now."),
)
def handle_set_output(
tool_input: dict[str, Any],
output_keys: list[str] | None,
) -> ToolResult:
"""Handle set_output tool call. Returns ToolResult (sync)."""
import logging
import re
logger = logging.getLogger(__name__)
key = tool_input.get("key", "")
value = tool_input.get("value", "")
valid_keys = output_keys or []
# Recover from truncated JSON (max_tokens hit mid-argument).
# The _raw key is set by litellm when json.loads fails.
if not key and "_raw" in tool_input:
raw = tool_input["_raw"]
key_match = re.search(r'"key"\s*:\s*"(\w+)"', raw)
if key_match:
key = key_match.group(1)
val_match = re.search(r'"value"\s*:\s*"', raw)
if val_match:
start = val_match.end()
value = raw[start:].rstrip()
for suffix in ('"}\n', '"}', '"'):
if value.endswith(suffix):
value = value[: -len(suffix)]
break
if key:
logger.warning(
"Recovered set_output args from truncated JSON: key=%s, value_len=%d",
key,
len(value),
)
# Re-inject so the caller sees proper key/value
tool_input["key"] = key
tool_input["value"] = value
if key not in valid_keys:
return ToolResult(
tool_use_id="",
content=f"Invalid output key '{key}'. Valid keys: {valid_keys}",
is_error=True,
)
return ToolResult(
tool_use_id="",
content=f"Output '{key}' set successfully.",
is_error=False,
)
@@ -0,0 +1,291 @@
"""Generic coercion of LLM-emitted tool arguments to match each tool's JSON schema.
Small/mid-size models drift from tool schemas in predictable, boring ways:
- A number field comes back as a string (``"42"`` instead of ``42``).
- A boolean field comes back as a string (``"true"`` instead of ``True``).
- An array-of-string field comes back as an array of objects
(``[{"label": "A"}, ...]`` instead of ``["A", ...]``).
- An array/object field comes back as a JSON-encoded string
(``'["A","B"]'`` instead of ``["A", "B"]``).
- A lone scalar arrives where the schema expects an array.
This module centralizes the healing in one schema-driven pass that runs
on every tool call before dispatch. Coercion is conservative:
- Values that already match the expected type are untouched.
- Shapes we don't recognize are returned as-is, so real bugs surface
instead of getting silently munged into something plausible.
- Every actual coercion is logged with the tool, property, and shape
transition so we can see which models/tools are drifting.
Tool-specific prompt drift (e.g. ``</question>`` tags leaking into an
``ask_user`` prompt string) is NOT this module's job — that belongs in
per-tool sanitizers, because it's about prompt style, not schema shape.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from framework.llm.provider import Tool
logger = logging.getLogger(__name__)
# When an ``array<string>`` field arrives as an array of objects, look
# for a text-carrying field in preference order. Covers the wrappers
# small models tend to produce: ``[{"label": "A"}]``, ``[{"value": "A"}]``,
# ``[{"text": "A"}]``, etc.
_STRING_EXTRACT_KEYS: tuple[str, ...] = (
"label",
"value",
"text",
"name",
"title",
"display",
)
def coerce_tool_input(tool: Tool, raw_input: dict[str, Any] | None) -> dict[str, Any]:
"""Coerce *raw_input* in place to match *tool*'s JSON schema.
Returns the mutated input dict (same object as *raw_input* when
possible, for callers that assume in-place mutation). Properties
not present in the schema are left untouched.
"""
if not isinstance(raw_input, dict):
return raw_input or {}
schema = tool.parameters or {}
props = schema.get("properties")
if not isinstance(props, dict):
return raw_input
for key in list(raw_input.keys()):
prop_schema = props.get(key)
if not isinstance(prop_schema, dict):
continue
original = raw_input[key]
coerced = _coerce(original, prop_schema)
if coerced is not original:
logger.info(
"coerced tool input tool=%s prop=%s from=%s to=%s",
tool.name,
key,
_shape(original),
_shape(coerced),
)
raw_input[key] = coerced
return raw_input
def _coerce(value: Any, schema: dict[str, Any]) -> Any:
"""Dispatch on the schema's ``type`` field.
Returns the *same object* on passthrough so callers can detect
no-ops via identity (``coerced is value``).
"""
expected = schema.get("type")
if not expected:
return value
# Union type: try each in order, return the first coercion that
# actually changes the value. Falls back to the original.
if isinstance(expected, list):
for t in expected:
sub_schema = {**schema, "type": t}
coerced = _coerce(value, sub_schema)
if coerced is not value:
return coerced
return value
if expected == "integer":
return _coerce_integer(value)
if expected == "number":
return _coerce_number(value)
if expected == "boolean":
return _coerce_boolean(value)
if expected == "string":
return _coerce_string(value)
if expected == "array":
return _coerce_array(value, schema)
if expected == "object":
return _coerce_object(value, schema)
return value
def _coerce_integer(value: Any) -> Any:
# bool is a subclass of int in Python; don't mistake True for 1 here.
if isinstance(value, bool):
return value
if isinstance(value, int):
return value
if isinstance(value, str):
parsed = _parse_number(value)
if parsed is None:
return value
if parsed != int(parsed):
# Has a fractional part — caller asked for int, don't truncate.
return value
return int(parsed)
return value
def _coerce_number(value: Any) -> Any:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
parsed = _parse_number(value)
if parsed is None:
return value
if parsed == int(parsed):
return int(parsed)
return parsed
return value
def _coerce_boolean(value: Any) -> Any:
if isinstance(value, bool):
return value
if isinstance(value, str):
low = value.strip().lower()
if low == "true":
return True
if low == "false":
return False
return value
def _coerce_string(value: Any) -> Any:
if isinstance(value, str):
return value
# Common drift: model sent ``{"label": "..."}`` when we wanted "...".
if isinstance(value, dict):
extracted = _extract_string_from_object(value)
if extracted is not None:
return extracted
return value
def _coerce_array(value: Any, schema: dict[str, Any]) -> Any:
# Heal: JSON-encoded array string → array.
if isinstance(value, str):
parsed = _try_parse_json(value)
if isinstance(parsed, list):
value = parsed
else:
# Scalar string where an array is expected — wrap it.
return [value]
elif not isinstance(value, list):
# Any other scalar (int, bool, dict, ...) — wrap.
return [value]
items_schema = schema.get("items")
if not isinstance(items_schema, dict):
return value
coerced_items: list[Any] = []
changed = False
for item in value:
c = _coerce(item, items_schema)
if c is not item:
changed = True
coerced_items.append(c)
return coerced_items if changed else value
def _coerce_object(value: Any, schema: dict[str, Any]) -> Any:
# Heal: JSON-encoded object string → object.
if isinstance(value, str):
parsed = _try_parse_json(value)
if isinstance(parsed, dict):
value = parsed
else:
return value
if not isinstance(value, dict):
return value
sub_props = schema.get("properties")
if not isinstance(sub_props, dict):
return value
changed = False
for k in list(value.keys()):
sub_schema = sub_props.get(k)
if not isinstance(sub_schema, dict):
continue
original = value[k]
coerced = _coerce(original, sub_schema)
if coerced is not original:
value[k] = coerced
changed = True
# Return the same dict on mutation so callers that passed a shared
# reference see the updates. ``changed`` is only used to decide
# whether we need to log at a coarser level upstream.
return value if changed or not sub_props else value
def _extract_string_from_object(obj: dict[str, Any]) -> str | None:
"""Pick a likely-text field out of a wrapper object.
Tries the known keys first, falls back to the sole value if the
object has exactly one entry. Returns None when nothing plausible
is found the caller keeps the original.
"""
for k in _STRING_EXTRACT_KEYS:
v = obj.get(k)
if isinstance(v, str) and v:
return v
if len(obj) == 1:
(only,) = obj.values()
if isinstance(only, str) and only:
return only
return None
def _try_parse_json(raw: str) -> Any:
try:
return json.loads(raw)
except (ValueError, TypeError):
return None
def _parse_number(raw: str) -> float | None:
try:
f = float(raw)
except (ValueError, OverflowError):
return None
# Reject NaN and inf — they pass float() but aren't useful numeric
# values for tool arguments.
if f != f or f == float("inf") or f == float("-inf"):
return None
return f
def _shape(value: Any) -> str:
"""Short type/shape description used in coercion log lines."""
if value is None:
return "None"
if isinstance(value, bool):
return "bool"
if isinstance(value, int):
return "int"
if isinstance(value, float):
return "float"
if isinstance(value, str):
return f"str[{len(value)}]"
if isinstance(value, list):
if not value:
return "list[0]"
return f"list[{len(value)}]<{_shape(value[0])}>"
if isinstance(value, dict):
keys = sorted(value.keys())[:3]
suffix = ",…" if len(value) > 3 else ""
return f"dict{{{','.join(keys)}{suffix}}}"
return type(value).__name__
@@ -0,0 +1,548 @@
"""Tool result handling: truncation, spillover, JSON preview, and execution.
Manages tool result size limits, file spillover for large results, and
smart JSON previews. Also includes transient error classification and
the context-window-exceeded error detector.
"""
from __future__ import annotations
import asyncio
import contextvars
import json
import logging
import re
from pathlib import Path
from typing import Any
from framework.llm.provider import ToolResult, ToolUse
from framework.llm.stream_events import ToolCallEvent
logger = logging.getLogger(__name__)
# Pattern for detecting context-window-exceeded errors across LLM providers.
_CONTEXT_TOO_LARGE_RE = re.compile(
r"context.{0,20}(length|window|limit|size)|"
r"too.{0,10}(long|large|many.{0,10}tokens)|"
r"(exceed|exceeds|exceeded).{0,30}(limit|window|context|tokens)|"
r"maximum.{0,20}token|prompt.{0,20}too.{0,10}long",
re.IGNORECASE,
)
def is_context_too_large_error(exc: BaseException) -> bool:
"""Detect whether an exception indicates the LLM input was too large."""
cls = type(exc).__name__
if "ContextWindow" in cls:
return True
return bool(_CONTEXT_TOO_LARGE_RE.search(str(exc)))
def is_transient_error(exc: BaseException) -> bool:
"""Classify whether an exception is transient (retryable) vs permanent.
Transient: network errors, rate limits, server errors, timeouts.
Permanent: auth errors, bad requests, context window exceeded.
"""
try:
from litellm.exceptions import (
APIConnectionError,
BadGatewayError,
InternalServerError,
RateLimitError,
ServiceUnavailableError,
)
transient_types: tuple[type[BaseException], ...] = (
RateLimitError,
APIConnectionError,
InternalServerError,
BadGatewayError,
ServiceUnavailableError,
TimeoutError,
ConnectionError,
OSError,
)
except ImportError:
transient_types = (TimeoutError, ConnectionError, OSError)
if isinstance(exc, transient_types):
return True
# RuntimeError from StreamErrorEvent with "Stream error:" prefix
if isinstance(exc, RuntimeError):
error_str = str(exc).lower()
transient_keywords = [
"rate limit",
"429",
"timeout",
"connection",
"internal server",
"502",
"503",
"504",
"service unavailable",
"bad gateway",
"overloaded",
"failed to parse tool call",
]
return any(kw in error_str for kw in transient_keywords)
return False
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 = 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 ""
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(
result: ToolResult,
tool_name: str,
*,
max_tool_result_chars: int,
spillover_dir: str | None,
next_spill_filename_fn: Any, # Callable[[str], str]
) -> ToolResult:
"""Persist tool result to file and optionally truncate for context.
When *spillover_dir* is configured, EVERY non-error tool result is
written to disk for debugging. The LLM-visible content is then
shaped to avoid a **poison pattern** that we traced on 2026-04-15
through a gemini-3.1-pro-preview-customtools queen session: the prior format
appended ``\\n\\n[Saved to '/abs/path/file.txt']`` after every
small result, and frontier pattern-matching models (gemini 3.x in
particular) learned to autocomplete the `[Saved to '...']` trailer
in their own assistant turns, eventually degenerating into echoing
the whole tool result instead of deciding what to do next. See
``session_20260415_100751_d49f4c28/conversations/parts/0000000056.json``
for the terminal case where the model's "text" output was the full
tool_result JSON.
Rules after the fix:
- **Small results ( limit):** pass content through unchanged. No
trailer. No annotation. The full content is already in the
message; the disk copy is for debugging only.
- **Large results (> limit):** preview + file reference, but
formatted as plain prose instead of a bracketed ``[...]``
pattern. Structured JSON metadata ("_saved_to") is embedded
inside the JSON body when the preview is JSON-shaped so the
model can locate the full file without seeing a mimicry-prone
bracket token outside the body.
- **Errors:** pass through unchanged.
- **read_file results:** truncate with pagination hint (no re-spill).
"""
limit = max_tool_result_chars
# Errors always pass through unchanged
if result.is_error:
return result
# read_file reads FROM spilled files — never re-spill (circular).
# Just truncate with a pagination hint if the result is too large.
if tool_name == "read_file":
if limit <= 0 or len(result.content) <= limit:
return result # Small result — pass through as-is
# Large 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 = extract_json_metadata(parsed_ld)
smart_preview = 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] + ""
# Prose header (no brackets).
header = (
f"Tool `{tool_name}` returned {len(result.content):,} characters "
f"(too large for context). Use offset_bytes / limit_bytes "
f"parameters to paginate smaller chunks."
)
if metadata_str:
header += f"\n\nData structure:\n{metadata_str}"
header += (
"\n\nWARNING: the preview below is a SAMPLE only — do NOT draw counts, totals, or conclusions from it."
)
truncated = f"{header}\n\nPreview (truncated):\n{preview_block}"
logger.info(
"%s result truncated: %d%d chars (use offset/limit to paginate)",
tool_name,
len(result.content),
len(truncated),
)
return ToolResult(
tool_use_id=result.tool_use_id,
content=truncated,
is_error=False,
image_content=result.image_content,
is_skill_content=result.is_skill_content,
)
spill_dir = spillover_dir
if spill_dir:
spill_path = Path(spill_dir)
spill_path.mkdir(parents=True, exist_ok=True)
filename = next_spill_filename_fn(tool_name)
# Pretty-print JSON content so read_file's line-based
# pagination works correctly.
write_content = result.content
parsed_json: Any = None # track for metadata extraction
try:
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
file_path = spill_path / filename
file_path.write_text(write_content, encoding="utf-8")
# Use absolute path so parent agents can find files from subagents
abs_path = str(file_path.resolve())
if limit > 0 and len(result.content) > limit:
# Large result: build a small, metadata-rich preview so the
# LLM cannot mistake it for the complete dataset. The
# preview is introduced as plain prose (no bracketed
# ``[Result from …]`` token) so it doesn't prime the model
# to autocomplete the same pattern in its next turn.
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 = extract_json_metadata(parsed_json)
smart_preview = 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] + ""
# Prose header (no brackets). Absolute path still surfaced
# so the agent can read the full file, but it's framed as
# a sentence, not a bracketed trailer.
header = (
f"Tool `{tool_name}` returned {len(result.content):,} characters "
f"(too large for context). Full result saved at: {abs_path}\n"
f"Read the complete data with read_file(path='{abs_path}').\n"
)
if metadata_str:
header += f"\nData structure:\n{metadata_str}\n"
header += (
"\nWARNING: the preview below is a SAMPLE only — do NOT draw counts, totals, or conclusions from it."
)
content = f"{header}\n\nPreview (truncated):\n{preview_block}"
logger.info(
"Tool result spilled to file: %s (%d chars → %s)",
tool_name,
len(result.content),
abs_path,
)
else:
# Small result: pass content through UNCHANGED.
#
# The prior design appended `\n\n[Saved to '/abs/path']`
# after every small result so the agent could re-read the
# file later. But (a) the full content is already in the
# message, so there's nothing to re-read; (b) the
# `[Saved to '…']` trailer is a repeating token pattern
# that frontier pattern-matching models autocomplete into
# their own assistant turns, eventually echoing whole tool
# results as "text" instead of making decisions. Dropping
# the trailer entirely kills the poison pattern. Spilled
# files on disk still exist for debugging — they just
# aren't advertised in the LLM-visible message.
content = result.content
logger.info(
"Tool result saved to file: %s (%d chars → %s, no trailer)",
tool_name,
len(result.content),
filename,
)
return ToolResult(
tool_use_id=result.tool_use_id,
content=content,
is_error=False,
image_content=result.image_content,
is_skill_content=result.is_skill_content,
)
# No spillover_dir — truncate in-place if needed
if limit > 0 and len(result.content) > limit:
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 = extract_json_metadata(parsed_inline)
smart_preview = 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] + ""
# Prose header (no brackets) — see docstring for the poison
# pattern that the bracket format triggered.
header = (
f"Tool `{tool_name}` returned {len(result.content):,} characters "
f"(truncated to fit context budget — no spillover dir configured)."
)
if metadata_str:
header += f"\n\nData structure:\n{metadata_str}"
header += (
"\n\nWARNING: the preview below is a SAMPLE only — do NOT draw counts, totals, or conclusions from it."
)
truncated = f"{header}\n\n{preview_block}"
logger.info(
"Tool result truncated in-place: %s (%d%d chars)",
tool_name,
len(result.content),
len(truncated),
)
return ToolResult(
tool_use_id=result.tool_use_id,
content=truncated,
is_error=False,
image_content=result.image_content,
is_skill_content=result.is_skill_content,
)
return result
async def execute_tool(
tool_executor: Any, # Callable[[ToolUse], ToolResult | Awaitable[ToolResult]] | None
tc: ToolCallEvent,
timeout: float,
skill_dirs: list[str] | None = None,
) -> ToolResult:
"""Execute a tool call, handling both sync and async executors.
Applies ``tool_call_timeout_seconds`` 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 don't
freeze the event loop.
"""
if tool_executor is None:
return ToolResult(
tool_use_id=tc.tool_use_id,
content=f"No tool executor configured for '{tc.tool_name}'",
is_error=True,
)
skill_dirs = skill_dirs or []
skill_read_tools = {"view_file", "read_file"}
if tc.tool_name in skill_read_tools and skill_dirs:
raw_path = tc.tool_input.get("path", "")
if raw_path:
resolved = Path(raw_path).resolve(strict=False)
resolved_roots = [Path(skill_dir).resolve(strict=False) for skill_dir in skill_dirs]
if any(resolved.is_relative_to(root) for root in resolved_roots):
try:
content = resolved.read_text(encoding="utf-8")
except Exception as exc:
return ToolResult(
tool_use_id=tc.tool_use_id,
content=f"Could not read skill resource '{raw_path}': {exc}",
is_error=True,
)
return ToolResult(
tool_use_id=tc.tool_use_id,
content=content,
is_skill_content=resolved.name == "SKILL.md",
)
tool_use = ToolUse(id=tc.tool_use_id, name=tc.tool_name, input=tc.tool_input)
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.
# Copy the current context so contextvars (e.g. data_dir from
# execution context) propagate into the worker thread.
loop = asyncio.get_running_loop()
ctx = contextvars.copy_context()
result = await loop.run_in_executor(None, ctx.run, 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)
# asyncio.wait_for cancels the awaiting coroutine, but the sync
# executor running inside run_in_executor keeps going — and so
# does any MCP subprocess it is blocked on. Reach through to the
# owning MCPClient and force-disconnect it so the subprocess is
# torn down. Next call_tool triggers a reconnect. Without this
# the executor thread and MCP child leak on every timeout.
kill_for_tool = getattr(tool_executor, "kill_for_tool", None)
if callable(kill_for_tool):
try:
await asyncio.to_thread(kill_for_tool, tc.tool_name)
except Exception as exc: # defensive — never let cleanup crash the loop
logger.warning(
"kill_for_tool('%s') raised during timeout handling: %s",
tc.tool_name,
exc,
)
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 restore_spill_counter(spillover_dir: str | None) -> int:
"""Scan spillover_dir for existing spill files and return the max counter.
Returns the highest spill number found (or 0 if none).
"""
if not spillover_dir:
return 0
spill_path = Path(spillover_dir)
if not spill_path.is_dir():
return 0
max_n = 0
for f in spill_path.iterdir():
if not f.is_file():
continue
m = re.search(r"_(\d+)\.txt$", f.name)
if m:
max_n = max(max_n, int(m.group(1)))
return max_n
@@ -0,0 +1,309 @@
"""Shared types and state containers for the event loop package."""
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, Protocol, runtime_checkable
from framework.agent_loop.conversation import (
ConversationStore,
)
logger = logging.getLogger(__name__)
@dataclass
class TriggerEvent:
"""A framework-level trigger signal (timer tick or webhook hit)."""
trigger_type: str
source_id: str
payload: dict[str, Any] = field(default_factory=dict)
timestamp: float = field(default_factory=time.time)
@dataclass
class JudgeVerdict:
"""Result of judge evaluation for the event loop."""
action: Literal["ACCEPT", "RETRY", "ESCALATE"]
# None = no evaluation happened (skip_judge, tool-continue); not logged.
# "" = evaluated but no feedback; logged with default text.
# "..." = evaluated with feedback; logged as-is.
feedback: str | None = None
@runtime_checkable
class JudgeProtocol(Protocol):
"""Protocol for event-loop judges."""
async def evaluate(self, context: dict[str, Any]) -> JudgeVerdict: ...
@dataclass
class LoopConfig:
"""Configuration for the event loop."""
max_iterations: int = 50
# 0 (or any non-positive value) disables the per-turn hard limit,
# letting a single assistant turn fan out arbitrarily many tool
# calls. Models like Gemini 3.1 Pro routinely emit 40-80 tool
# calls in one turn during browser exploration; capping them
# strands work half-finished and makes the next turn repeat the
# discarded calls, which is worse than just running them.
max_tool_calls_per_turn: int = 0
judge_every_n_turns: int = 1
stall_detection_threshold: int = 3
stall_similarity_threshold: float = 0.85
max_context_tokens: int = 32_000
# Headroom reserved for the NEXT turn's input + output so that
# proactive compaction always finishes before the hard context limit
# is hit mid-stream. Scaled to match Claude Code's 13k-buffer-on-
# 200k-window ratio (~6.5%) applied to hive's default 32k window,
# with extra margin because hive's token estimator is char-based
# and less tight than Anthropic's own counting. Override via
# LoopConfig for larger windows.
compaction_buffer_tokens: int = 8_000
# Warning is emitted one buffer earlier so the user/telemetry gets
# a "we're close" signal without triggering a compaction pass.
compaction_warning_buffer_tokens: int = 12_000
store_prefix: str = ""
# Overflow margin for max_tool_calls_per_turn. When the limit is
# enabled (>0), tool calls are only discarded when the count
# exceeds max_tool_calls_per_turn * (1 + margin). Ignored when
# max_tool_calls_per_turn is 0.
tool_call_overflow_margin: float = 0.5
# Tool result context management.
max_tool_result_chars: int = 30_000
spillover_dir: str | None = None
# Image retention in conversation history.
# Screenshots from ``browser_screenshot`` are inlined as base64
# data URLs inside message ``image_content``. Each full-page
# screenshot costs ~250k tokens when the provider counts the
# base64 as text (gemini, most non-Anthropic providers). Four
# screenshots in one conversation push gemini's 1M context over
# the limit and the model starts emitting garbage.
#
# The framework strips image_content from older messages after
# every tool-result batch, keeping only the most recent N
# screenshots. The text metadata on evicted messages (url, size,
# scale hints) is preserved so the agent can still reason about
# "I took a screenshot at step N that showed the compose modal".
# Raise this only if you genuinely need longer visual history AND
# you know your provider is using native image tokenization.
max_retained_screenshots: int = 2
# set_output value spilling.
max_output_value_chars: int = 2_000
# Stream retry.
max_stream_retries: int = 5
stream_retry_backoff_base: float = 2.0
stream_retry_max_delay: float = 60.0
# Persistent retry for capacity-class errors (429, 529, overloaded).
# Unlike the bounded retry above, these keep trying until the wall-clock
# budget below is exhausted — modelled after claude-code's withRetry.
# The loop still publishes a retry event each attempt so the UI can
# see progress. Set to 0 to disable and fall back to bounded retry.
capacity_retry_max_seconds: float = 600.0
capacity_retry_max_delay: float = 60.0
# Tool doom loop detection.
tool_doom_loop_threshold: int = 3
# Client-facing auto-block grace period.
cf_grace_turns: int = 1
# Worker auto-escalation: text-only turns before escalating to queen.
worker_escalation_grace_turns: int = 1
tool_doom_loop_enabled: bool = True
# Silent worker: consecutive tool-only turns (no user-facing text)
# before injecting a nudge to communicate progress.
silent_tool_streak_threshold: int = 5
# Per-tool-call timeout.
tool_call_timeout_seconds: float = 60.0
# LLM stream inactivity watchdog. Split into two budgets so legitimate
# slow TTFT on large contexts doesn't get mistaken for a dead connection.
# - ttft: stream open -> first event. Large-context local models can
# legitimately take minutes before the first token arrives.
# - inter_event: last event -> now, ONLY after the first event. A stream
# that started producing and then went silent is a real stall.
# Whichever fires first cancels the stream. Set to 0 to disable that
# individual budget; set both to 0 to fully disable the watchdog.
llm_stream_ttft_timeout_seconds: float = 600.0
llm_stream_inter_event_idle_seconds: float = 120.0
# Deprecated alias — kept so existing configs keep working. If set to a
# non-default value it overrides inter_event_idle (historical behavior).
llm_stream_inactivity_timeout_seconds: float = 120.0
# Continue-nudge recovery. When the idle watchdog fires on a live but
# stuck stream, cancel the stream and append a short continuation
# hint to the conversation instead of raising a ConnectionError and
# re-running the whole turn. Preserves any partial text/tool-calls the
# stream emitted before the stall.
continue_nudge_enabled: bool = True
# Cap so a truly dead endpoint eventually falls back to the error path
# instead of nudging forever.
continue_nudge_max_per_turn: int = 3
# Tool-call replay detector. When the model emits a tool call whose
# (name + canonical-args) matches a prior successful call in the last
# K assistant turns, emit telemetry and prepend a short steer onto the
# tool result — but still execute. Weaker models legitimately repeat
# read-only calls (screenshot, evaluate), so silent skipping would
# cause surprising behavior.
replay_detector_enabled: bool = True
replay_detector_within_last_turns: int = 3
# Subagent delegation timeout (wall-clock max).
subagent_timeout_seconds: float = 3600.0
# Subagent inactivity timeout - only timeout if no activity for this duration.
# This resets whenever the subagent makes progress (tool calls, LLM responses).
# Set to 0 to use only the wall-clock timeout.
subagent_inactivity_timeout_seconds: float = 300.0
# Lifecycle hooks.
hooks: dict[str, list] | None = None
def __post_init__(self) -> None:
if self.hooks is None:
object.__setattr__(self, "hooks", {})
@dataclass
class HookContext:
"""Context passed to every lifecycle hook."""
event: str
trigger: str | None
system_prompt: str
@dataclass
class HookResult:
"""What a hook may return to modify node state."""
system_prompt: str | None = None
inject: str | None = None
@dataclass
class OutputAccumulator:
"""Accumulates output key-value pairs with optional write-through persistence."""
values: dict[str, Any] = field(default_factory=dict)
store: ConversationStore | None = None
spillover_dir: str | None = None
max_value_chars: int = 0
run_id: str | None = None
async def set(self, key: str, value: Any) -> None:
"""Set a key-value pair, auto-spilling large values to files."""
value = await self._auto_spill(key, value)
self.values[key] = value
if self.store:
cursor = await self.store.read_cursor() or {}
outputs = cursor.get("outputs", {})
outputs[key] = value
cursor["outputs"] = outputs
await self.store.write_cursor(cursor)
async def _auto_spill(self, key: str, value: Any) -> Any:
"""Save large values to a file and return a reference string.
Runs the JSON serialization and file write on a worker thread
so they don't block the asyncio event loop. For a 100k-char
dict this used to freeze every concurrent tool call for ~50ms
of ``json.dumps(indent=2)`` + a sync disk write; for bigger
payloads or slow storage (NFS, networked FS) the freeze was
proportionally worse.
"""
if self.max_value_chars <= 0 or not self.spillover_dir:
return value
# Cheap size probe first — if the value is already a short
# string we can skip both the JSON round-trip and the thread
# hop entirely.
if isinstance(value, str) and len(value) <= self.max_value_chars:
return value
def _spill_sync() -> Any:
# JSON serialization for size check (only for non-strings).
if isinstance(value, str):
val_str = value
else:
val_str = json.dumps(value, ensure_ascii=False)
if len(val_str) <= self.max_value_chars:
return value
spill_path = Path(self.spillover_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)
)
file_path = spill_path / filename
file_path.write_text(write_content, encoding="utf-8")
file_size = file_path.stat().st_size
logger.info(
"set_output value auto-spilled: key=%s, %d chars -> %s (%d bytes)",
key,
len(val_str),
filename,
file_size,
)
# Use absolute path so parent agents can find files from subagents.
#
# Prose format (no brackets) — same fix as tool_result_handler:
# frontier pattern-matching models autocomplete bracketed
# `[Saved to '...']` trailers into their own assistant turns,
# eventually degenerating into echoing the file path as text.
# Keep the path accessible but frame it as plain prose.
abs_path = str(file_path.resolve())
return (
f"Output saved at: {abs_path} ({file_size:,} bytes). "
f"Read the full data with read_file(path='{abs_path}')."
)
return await asyncio.to_thread(_spill_sync)
def get(self, key: str) -> Any | None:
return self.values.get(key)
def to_dict(self) -> dict[str, Any]:
return dict(self.values)
def has_all_keys(self, required: list[str]) -> bool:
return all(key in self.values and self.values[key] is not None for key in required)
@classmethod
async def restore(
cls,
store: ConversationStore,
run_id: str | None = None,
) -> OutputAccumulator:
cursor = await store.read_cursor()
values = cursor.get("outputs", {}) if cursor else {}
return cls(values=values, store=store, run_id=run_id)
__all__ = [
"HookContext",
"HookResult",
"JudgeProtocol",
"JudgeVerdict",
"LoopConfig",
"OutputAccumulator",
"TriggerEvent",
]
+98
View File
@@ -0,0 +1,98 @@
"""Prompt composition for agent loops.
Builds canonical system prompts from AgentContext fields.
Extracted from the former orchestrator/prompting module.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
@dataclass(frozen=True)
class PromptSpec:
identity_prompt: str = ""
focus_prompt: str = ""
narrative: str = ""
accounts_prompt: str = ""
skills_catalog_prompt: str = ""
protocols_prompt: str = ""
memory_prompt: str = ""
agent_type: str = "event_loop"
output_keys: tuple[str, ...] = ()
def stamp_prompt_datetime(prompt: str) -> str:
local = datetime.now().astimezone()
stamp = f"Current date and time: {local.strftime('%Y-%m-%d %H:%M %Z (UTC%z)')}"
return f"{prompt}\n\n{stamp}" if prompt else stamp
def build_prompt_spec(
ctx: Any,
*,
focus_prompt: str | None = None,
narrative: str | None = None,
memory_prompt: str | None = None,
) -> PromptSpec:
from framework.skills.tool_gating import augment_catalog_for_tools
resolved_memory = memory_prompt
if resolved_memory is None:
resolved_memory = getattr(ctx, "memory_prompt", "") or ""
dynamic = getattr(ctx, "dynamic_memory_provider", None)
if dynamic is not None:
try:
resolved_memory = dynamic() or ""
except Exception:
resolved_memory = getattr(ctx, "memory_prompt", "") or ""
# Tool-gated pre-activation: inject full body of default skills whose
# trigger tools are present in this agent's tool list (e.g. browser_*
# pulls in hive.browser-automation). Keeps non-browser agents lean.
tool_names = [getattr(t, "name", "") for t in (getattr(ctx, "available_tools", None) or [])]
skills_catalog_prompt = augment_catalog_for_tools(ctx.skills_catalog_prompt or "", tool_names)
return PromptSpec(
identity_prompt=ctx.identity_prompt or "",
focus_prompt=focus_prompt if focus_prompt is not None else (ctx.agent_spec.system_prompt or ""),
narrative=narrative if narrative is not None else (ctx.narrative or ""),
accounts_prompt=ctx.accounts_prompt or "",
skills_catalog_prompt=skills_catalog_prompt,
protocols_prompt=ctx.protocols_prompt or "",
memory_prompt=resolved_memory,
agent_type=ctx.agent_spec.agent_type,
output_keys=tuple(ctx.agent_spec.output_keys or ()),
)
def build_system_prompt(spec: PromptSpec) -> str:
parts: list[str] = []
if spec.identity_prompt:
parts.append(spec.identity_prompt)
if spec.accounts_prompt:
parts.append(f"\n{spec.accounts_prompt}")
if spec.skills_catalog_prompt:
parts.append(f"\n{spec.skills_catalog_prompt}")
if spec.protocols_prompt:
parts.append(f"\n{spec.protocols_prompt}")
if spec.memory_prompt:
parts.append(f"\n{spec.memory_prompt}")
if spec.focus_prompt:
parts.append(f"\n{spec.focus_prompt}")
if spec.narrative:
parts.append(f"\n{spec.narrative}")
return "\n".join(parts)
def build_system_prompt_for_context(
ctx: Any,
*,
focus_prompt: str | None = None,
narrative: str | None = None,
memory_prompt: str | None = None,
) -> str:
spec = build_prompt_spec(ctx, focus_prompt=focus_prompt, narrative=narrative, memory_prompt=memory_prompt)
return build_system_prompt(spec)
+264
View File
@@ -0,0 +1,264 @@
"""Core types for the agent loop — the execution primitive of the colony.
AgentSpec: Declarative definition of what an agent does.
AgentContext: Everything an agent loop needs to execute.
AgentResult: What comes out of an agent loop execution.
AgentProtocol: Interface that all agent implementations must satisfy.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
from pydantic import BaseModel, Field
from framework.llm.provider import LLMProvider, Tool
from framework.tracker.decision_tracker import DecisionTracker
class AgentSpec(BaseModel):
"""Declarative definition of an agent's capabilities and configuration.
This is the blueprint from which AgentLoop instances are created.
Workers in a colony are exact copies of the queen's AgentSpec.
"""
id: str
name: str
description: str
agent_type: str = Field(
default="event_loop",
description="Type: 'event_loop' (recommended), 'gcu' (browser automation).",
)
input_keys: list[str] = Field(
default_factory=list,
description="Keys this agent reads from input data",
)
output_keys: list[str] = Field(
default_factory=list,
description="Keys this agent produces as output",
)
nullable_output_keys: list[str] = Field(
default_factory=list,
description="Output keys that can be None without triggering validation errors",
)
input_schema: dict[str, dict] = Field(
default_factory=dict,
description="Optional schema for input validation.",
)
output_schema: dict[str, dict] = Field(
default_factory=dict,
description="Optional schema for output validation.",
)
system_prompt: str | None = Field(default=None, description="System prompt for the LLM")
tools: list[str] = Field(default_factory=list, description="Tool names this agent can use")
tool_access_policy: str = Field(
default="explicit",
description=(
"'all' = all tools from registry, "
"'explicit' = only tools listed in `tools` (default), "
"'none' = no tools at all."
),
)
model: str | None = Field(default=None, description="Specific model override")
function: str | None = Field(default=None, description="Function name or path")
routes: dict[str, str] = Field(default_factory=dict, description="Condition -> target mapping")
max_retries: int = Field(default=3)
retry_on: list[str] = Field(default_factory=list, description="Error types to retry on")
max_visits: int = Field(
default=0,
description=("Max times this agent executes in one colony run. 0 = unlimited. Set >1 for one-shot agents."),
)
output_model: type[BaseModel] | None = Field(
default=None,
description="Optional Pydantic model for validating LLM output.",
)
max_validation_retries: int = Field(
default=2,
description="Maximum retries when Pydantic validation fails",
)
client_facing: bool = Field(
default=False,
description="Deprecated — the queen is intrinsically interactive.",
)
success_criteria: str | None = Field(
default=None,
description="Natural-language criteria for phase completion.",
)
skip_judge: bool = Field(
default=False,
description="When True, the implicit judge is bypassed entirely.",
)
model_config = {"extra": "allow", "arbitrary_types_allowed": True}
def is_queen(self) -> bool:
return self.id == "queen"
def supports_direct_user_io(self) -> bool:
return self.is_queen()
def deprecated_client_facing_warning(spec: AgentSpec) -> str | None:
if spec.client_facing and not spec.is_queen():
return (
f"Agent '{spec.id}' sets deprecated client_facing=True. "
"Non-queen direct human I/O is no longer supported; route worker "
"questions and approvals through queen escalation instead."
)
return None
def warn_if_deprecated_client_facing(spec: AgentSpec) -> None:
import logging
warning = deprecated_client_facing_warning(spec)
if warning:
logging.getLogger(__name__).warning(warning)
@dataclass
class AgentContext:
"""Everything an agent loop needs to execute.
Passed to every agent implementation and provides:
- Runtime (for decision logging)
- LLM access
- Tools
- Goal context
- Execution metadata
"""
runtime: DecisionTracker
agent_id: str
agent_spec: AgentSpec
input_data: dict[str, Any] = field(default_factory=dict)
llm: LLMProvider | None = None
available_tools: list[Tool] = field(default_factory=list)
goal_context: str = ""
goal: Any = None
max_tokens: int = 4096
attempt: int = 1
max_attempts: int = 3
runtime_logger: Any = None
pause_event: Any = None
accounts_prompt: str = ""
identity_prompt: str = ""
narrative: str = ""
memory_prompt: str = ""
event_triggered: bool = False
execution_id: str = ""
run_id: str = ""
@property
def effective_run_id(self) -> str | None:
return self.run_id or None
stream_id: str = ""
dynamic_tools_provider: Any = None
dynamic_prompt_provider: Any = None
dynamic_memory_provider: Any = None
skills_catalog_prompt: str = ""
protocols_prompt: str = ""
skill_dirs: list[str] = field(default_factory=list)
default_skill_batch_nudge: str | None = None
default_skill_warn_ratio: float | None = None
iteration_metadata_provider: Any = None
@property
def is_queen_stream(self) -> bool:
return self.stream_id == "queen" or self.agent_spec.is_queen()
@property
def emits_client_io(self) -> bool:
return self.is_queen_stream
@property
def supports_direct_user_io(self) -> bool:
return self.is_queen_stream and not self.event_triggered
@dataclass
class AgentResult:
"""Output of an agent loop execution."""
success: bool
output: dict[str, Any] = field(default_factory=dict)
error: str | None = None
next_agent: str | None = None
route_reason: str | None = None
tokens_used: int = 0
latency_ms: int = 0
validation_errors: list[str] = field(default_factory=list)
conversation: Any = None
# Machine-readable reason the loop stopped (see LoopExitReason in
# agent_loop/internals/types.py). "?" means the loop didn't set one,
# which should itself be treated as a diagnostic.
exit_reason: str = "?"
# Counters for reliability events surfaced during this execution.
# Populated from the loop's TaskRegistry-style counters at return
# time so callers can spot recurring failure modes without tailing
# logs. Keys are stable strings; missing keys mean "zero".
reliability_stats: dict[str, int] = field(default_factory=dict)
def to_summary(self, spec: Any = None) -> str:
if not self.success:
return f"Failed: {self.error}"
if not self.output:
return "Completed (no output)"
parts = [f"Completed with {len(self.output)} outputs:"]
for key, value in list(self.output.items())[:5]:
value_str = str(value)[:100]
if len(str(value)) > 100:
value_str += "..."
parts.append(f" - {key}: {value_str}")
return "\n".join(parts)
class AgentProtocol(ABC):
"""Interface all agent implementations must satisfy."""
@abstractmethod
async def execute(self, ctx: AgentContext) -> AgentResult:
pass
def validate_input(self, ctx: AgentContext) -> list[str]:
errors = []
for key in ctx.agent_spec.input_keys:
if key not in ctx.input_data:
errors.append(f"Missing required input: {key}")
return errors
+5 -1
View File
@@ -8,6 +8,10 @@ FRAMEWORK_AGENTS_DIR = Path(__file__).parent
def list_framework_agents() -> list[Path]:
"""List all framework agent directories."""
return sorted(
[p for p in FRAMEWORK_AGENTS_DIR.iterdir() if p.is_dir() and (p / "agent.py").exists()],
[
p
for p in FRAMEWORK_AGENTS_DIR.iterdir()
if p.is_dir() and ((p / "agent.json").exists() or (p / "agent.py").exists())
],
key=lambda p: p.name,
)
@@ -21,15 +21,15 @@ from pathlib import Path
from typing import TYPE_CHECKING
from framework.config import get_max_context_tokens
from framework.graph import Goal, NodeSpec, SuccessCriterion
from framework.graph.checkpoint_config import CheckpointConfig
from framework.graph.edge import GraphSpec
from framework.graph.executor import ExecutionResult
from framework.host.agent_host import AgentHost
from framework.host.execution_manager import EntryPointSpec
from framework.llm import LiteLLMProvider
from framework.runner.mcp_registry import MCPRegistry
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime
from framework.runtime.execution_stream import EntryPointSpec
from framework.loader.mcp_registry import MCPRegistry
from framework.loader.tool_registry import ToolRegistry
from framework.orchestrator import Goal, NodeSpec, SuccessCriterion
from framework.orchestrator.checkpoint_config import CheckpointConfig
from framework.orchestrator.edge import GraphSpec
from framework.orchestrator.orchestrator import ExecutionResult
from .config import default_config
from .nodes import build_tester_node
@@ -37,7 +37,7 @@ from .nodes import build_tester_node
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from framework.runner import AgentRunner
from framework.loader import AgentLoader
logger = logging.getLogger(__name__)
@@ -126,9 +126,7 @@ def _list_local_accounts() -> list[dict]:
try:
from framework.credentials.local.registry import LocalCredentialRegistry
return [
info.to_account_dict() for info in LocalCredentialRegistry.default().list_accounts()
]
return [info.to_account_dict() for info in LocalCredentialRegistry.default().list_accounts()]
except ImportError as exc:
logger.debug("Local credential registry unavailable: %s", exc)
return []
@@ -181,9 +179,7 @@ def _list_env_fallback_accounts() -> list[dict]:
if spec.credential_group in seen_groups:
continue
group_available = all(
_is_configured(n, s)
for n, s in CREDENTIAL_SPECS.items()
if s.credential_group == spec.credential_group
_is_configured(n, s) for n, s in CREDENTIAL_SPECS.items() if s.credential_group == spec.credential_group
)
if not group_available:
continue
@@ -215,9 +211,7 @@ def list_connected_accounts() -> list[dict]:
# Show env-var fallbacks only for credentials not already in the named registry
local_providers = {a["provider"] for a in local}
env_fallbacks = [
a for a in _list_env_fallback_accounts() if a["provider"] not in local_providers
]
env_fallbacks = [a for a in _list_env_fallback_accounts() if a["provider"] not in local_providers]
return aden + local + env_fallbacks
@@ -233,7 +227,7 @@ requires_account_selection = True
"""Signal TUI to show account picker before starting the agent."""
def configure_for_account(runner: AgentRunner, account: dict) -> None:
def configure_for_account(runner: AgentLoader, account: dict) -> None:
"""Scope the tester node's tools to the selected provider.
Handles both Aden accounts (account= routing) and local accounts
@@ -272,9 +266,7 @@ def _activate_local_account(credential_id: str, alias: str) -> None:
group_specs = [
(cred_name, spec)
for cred_name, spec in CREDENTIAL_SPECS.items()
if spec.credential_group == credential_id
or spec.credential_id == credential_id
or cred_name == credential_id
if spec.credential_group == credential_id or spec.credential_id == credential_id or cred_name == credential_id
]
# Deduplicate — credential_id and credential_group may both match the same spec
seen_env_vars: set[str] = set()
@@ -325,7 +317,7 @@ def _activate_local_account(credential_id: str, alias: str) -> None:
def _configure_aden_node(
runner: AgentRunner,
runner: AgentLoader,
provider: str,
alias: str,
detail: str,
@@ -368,7 +360,7 @@ or any other identifier — always use the alias exactly as shown.
def _configure_local_node(
runner: AgentRunner,
runner: AgentLoader,
provider: str,
alias: str,
identity: dict,
@@ -419,10 +411,7 @@ nodes = [
NodeSpec(
id="tester",
name="Credential Tester",
description=(
"Interactive credential testing — lets the user pick an account "
"and verify it via API calls."
),
description=("Interactive credential testing — lets the user pick an account and verify it via API calls."),
node_type="event_loop",
client_facing=True,
max_node_visits=0,
@@ -469,10 +458,7 @@ pause_nodes = []
terminal_nodes = ["tester"] # Tester node can terminate
conversation_mode = "continuous"
identity_prompt = (
"You are a credential tester that verifies connected accounts and API keys "
"can make real API calls."
)
identity_prompt = "You are a credential tester that verifies connected accounts and API keys can make real API calls."
loop_config = {
"max_iterations": 50,
"max_tool_calls_per_turn": 30,
@@ -497,7 +483,7 @@ class CredentialTesterAgent:
def __init__(self, config=None):
self.config = config or default_config
self._selected_account: dict | None = None
self._agent_runtime: AgentRuntime | None = None
self._agent_runtime: AgentHost | None = None
self._tool_registry: ToolRegistry | None = None
self._storage_path: Path | None = None
@@ -584,11 +570,19 @@ class CredentialTesterAgent:
self._tool_registry.load_mcp_config(mcp_config_path)
try:
agent_dir = Path(__file__).parent
registry = MCPRegistry()
registry.initialize()
registry_configs = registry.load_agent_selection(Path(__file__).parent)
if (agent_dir / "mcp_registry.json").is_file():
self._tool_registry.set_mcp_registry_agent_path(agent_dir)
registry_configs, selection_max_tools = registry.load_agent_selection(agent_dir)
if registry_configs:
self._tool_registry.load_registry_servers(registry_configs)
self._tool_registry.load_registry_servers(
registry_configs,
preserve_existing_tools=True,
log_collisions=True,
max_tools=selection_max_tools,
)
except Exception:
logger.warning("MCP registry config failed to load", exc_info=True)
@@ -605,7 +599,7 @@ class CredentialTesterAgent:
graph = self._build_graph()
self._agent_runtime = create_agent_runtime(
self._agent_runtime = AgentHost(
graph=graph,
goal=goal,
storage_path=self._storage_path,
@@ -1,9 +1,9 @@
{
"hive-tools": {
"hive_tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "mcp_server.py", "--stdio"],
"cwd": "../../../../tools",
"description": "Hive tools MCP server with provider-specific tools"
"description": "hive_tools MCP server with provider-specific tools"
}
}
@@ -1,6 +1,6 @@
"""Node definitions for Credential Tester agent."""
from framework.graph import NodeSpec
from framework.orchestrator import NodeSpec
def build_tester_node(
+154 -78
View File
@@ -4,9 +4,36 @@ from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import UTC
from pathlib import Path
@dataclass
class WorkerEntry:
"""A single worker within a colony."""
name: str
config_path: Path
description: str = ""
tool_count: int = 0
task: str = ""
spawned_at: str = ""
queen_name: str = ""
colony_name: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"config_path": str(self.config_path),
"description": self.description,
"tool_count": self.tool_count,
"task": self.task,
"spawned_at": self.spawned_at,
"queen_name": self.queen_name,
"colony_name": self.colony_name,
}
@dataclass
class AgentEntry:
"""Lightweight agent metadata for the picker / API discover endpoint."""
@@ -21,14 +48,17 @@ class AgentEntry:
tool_count: int = 0
tags: list[str] = field(default_factory=list)
last_active: str | None = None
created_at: str | None = None
icon: str | None = None
workers: list[WorkerEntry] = field(default_factory=list)
def _get_last_active(agent_path: Path) -> str | None:
"""Return the most recent updated_at timestamp across all sessions.
Checks both worker sessions (``~/.hive/agents/{name}/sessions/``) and
queen sessions (``~/.hive/queen/session/``) whose ``meta.json`` references
the same *agent_path*.
queen sessions (``~/.hive/agents/queens/default/sessions/``) whose
``meta.json`` references the same *agent_path*.
"""
from datetime import datetime
@@ -52,26 +82,33 @@ def _get_last_active(agent_path: Path) -> str | None:
except Exception:
continue
# 2. Queen sessions
queen_sessions_dir = Path.home() / ".hive" / "queen" / "session"
if queen_sessions_dir.exists():
# 2. Queen sessions (scan all queen identity directories)
from framework.config import QUEENS_DIR
if QUEENS_DIR.exists():
resolved = agent_path.resolve()
for d in queen_sessions_dir.iterdir():
if not d.is_dir():
for queen_dir in QUEENS_DIR.iterdir():
if not queen_dir.is_dir():
continue
meta_file = d / "meta.json"
if not meta_file.exists():
sessions_dir = queen_dir / "sessions"
if not sessions_dir.exists():
continue
try:
meta = json.loads(meta_file.read_text(encoding="utf-8"))
stored = meta.get("agent_path")
if not stored or Path(stored).resolve() != resolved:
for d in sessions_dir.iterdir():
if not d.is_dir():
continue
meta_file = d / "meta.json"
if not meta_file.exists():
continue
try:
meta = json.loads(meta_file.read_text(encoding="utf-8"))
stored = meta.get("agent_path")
if not stored or Path(stored).resolve() != resolved:
continue
ts = datetime.fromtimestamp(d.stat().st_mtime).isoformat()
if latest is None or ts > latest:
latest = ts
except Exception:
continue
ts = datetime.fromtimestamp(d.stat().st_mtime).isoformat()
if latest is None or ts > latest:
latest = ts
except Exception:
continue
return latest
@@ -109,85 +146,119 @@ def _count_runs(agent_name: str) -> int:
return len(run_ids)
_EXCLUDED_JSON_STEMS = {"agent", "flowchart", "triggers", "configuration", "metadata"}
def _is_colony_dir(path: Path) -> bool:
"""Check if a directory is a colony with worker config files."""
if not path.is_dir():
return False
return any(f.suffix == ".json" and f.stem not in _EXCLUDED_JSON_STEMS for f in path.iterdir() if f.is_file())
def _find_worker_configs(colony_dir: Path) -> list[Path]:
"""Find all worker config JSON files in a colony directory."""
return sorted(
p for p in colony_dir.iterdir() if p.is_file() and p.suffix == ".json" and p.stem not in _EXCLUDED_JSON_STEMS
)
def _extract_agent_stats(agent_path: Path) -> tuple[int, int, list[str]]:
"""Extract node count, tool count, and tags from an agent directory.
"""Extract worker count, tool count, and tags from a colony directory."""
tags: list[str] = []
Prefers agent.py (AST-parsed) over agent.json for node/tool counts
since agent.json may be stale. Tags are only available from agent.json.
"""
import ast
worker_configs = _find_worker_configs(agent_path)
if worker_configs:
all_tools: set[str] = set()
for wc_path in worker_configs:
try:
data = json.loads(wc_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
tools = data.get("tools", [])
if isinstance(tools, list):
all_tools.update(tools)
except Exception:
pass
return len(worker_configs), len(all_tools), tags
node_count, tool_count, tags = 0, 0, []
agent_py = agent_path / "agent.py"
if agent_py.exists():
try:
tree = ast.parse(agent_py.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "nodes":
if isinstance(node.value, ast.List):
node_count = len(node.value.elts)
except Exception:
pass
agent_json = agent_path / "agent.json"
if agent_json.exists():
try:
data = json.loads(agent_json.read_text(encoding="utf-8"))
json_nodes = data.get("graph", {}).get("nodes", []) or data.get("nodes", [])
if node_count == 0:
node_count = len(json_nodes)
tools: set[str] = set()
for n in json_nodes:
tools.update(n.get("tools", []))
tool_count = len(tools)
tags = data.get("agent", {}).get("tags", [])
except Exception:
pass
return node_count, tool_count, tags
return 0, 0, tags
def discover_agents() -> dict[str, list[AgentEntry]]:
"""Discover agents from all known sources grouped by category."""
from framework.runner.cli import (
_extract_python_agent_metadata,
_get_framework_agents_dir,
_is_valid_agent_dir,
)
from framework.config import COLONIES_DIR
groups: dict[str, list[AgentEntry]] = {}
sources = [
("Your Agents", Path("exports")),
("Framework", _get_framework_agents_dir()),
("Examples", Path("examples/templates")),
("Your Agents", COLONIES_DIR),
]
# Track seen agent directory names to avoid duplicates when the same
# agent exists in both colonies/ and exports/ (colonies takes priority).
_seen_agent_names: set[str] = set()
for category, base_dir in sources:
if not base_dir.exists():
continue
entries: list[AgentEntry] = []
for path in sorted(base_dir.iterdir(), key=lambda p: p.name):
if not _is_valid_agent_dir(path):
if not _is_colony_dir(path):
continue
if path.name in _seen_agent_names:
continue
_seen_agent_names.add(path.name)
name, desc = _extract_python_agent_metadata(path)
config_fallback_name = path.name.replace("_", " ").title()
used_config = name != config_fallback_name
name = config_fallback_name
desc = ""
node_count, tool_count, tags = _extract_agent_stats(path)
if not used_config:
agent_json = path / "agent.json"
if agent_json.exists():
try:
data = json.loads(agent_json.read_text(encoding="utf-8"))
meta = data.get("agent", {})
name = meta.get("name", name)
desc = meta.get("description", desc)
except Exception:
pass
# Read colony metadata for queen provenance and timestamps
colony_queen_name = ""
colony_created_at: str | None = None
colony_icon: str | None = None
metadata_path = path / "metadata.json"
if metadata_path.exists():
try:
mdata = json.loads(metadata_path.read_text(encoding="utf-8"))
colony_queen_name = mdata.get("queen_name", "")
colony_created_at = mdata.get("created_at")
colony_icon = mdata.get("icon")
except Exception:
pass
# Fallback: use directory creation time if metadata lacks created_at
if not colony_created_at:
try:
from datetime import datetime
stat = path.stat()
colony_created_at = datetime.fromtimestamp(stat.st_birthtime, tz=UTC).isoformat()
except Exception:
pass
worker_entries: list[WorkerEntry] = []
worker_configs = _find_worker_configs(path)
for wc_path in worker_configs:
try:
data = json.loads(wc_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
w = WorkerEntry(
name=data.get("name", wc_path.stem),
config_path=wc_path,
description=data.get("description", ""),
tool_count=len(data.get("tools", [])),
task=data.get("goal", {}).get("description", ""),
spawned_at=data.get("spawned_at", ""),
queen_name=colony_queen_name,
colony_name=path.name,
)
worker_entries.append(w)
if not desc:
desc = data.get("description", "")
except Exception:
pass
node_count = len(worker_entries)
tool_count = max((w.tool_count for w in worker_entries), default=0)
entries.append(
AgentEntry(
@@ -199,11 +270,16 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
run_count=_count_runs(path.name),
node_count=node_count,
tool_count=tool_count,
tags=tags,
tags=[],
last_active=_get_last_active(path),
created_at=colony_created_at,
icon=colony_icon,
workers=worker_entries,
)
)
if entries:
groups[category] = entries
existing = groups.get(category, [])
existing.extend(entries)
groups[category] = existing
return groups
+3 -9
View File
@@ -1,19 +1,13 @@
"""
Queen Native agent builder for the Hive framework.
"""Queen -- the agent builder for the Hive framework."""
Deeply understands the agent framework and produces complete Python packages
with goals, nodes, edges, system prompts, MCP configuration, and tests
from natural language specifications.
"""
from .agent import queen_goal, queen_graph
from .agent import queen_goal, queen_loop_config
from .config import AgentMetadata, RuntimeConfig, default_config, metadata
__version__ = "1.0.0"
__all__ = [
"queen_goal",
"queen_graph",
"queen_loop_config",
"RuntimeConfig",
"AgentMetadata",
"default_config",
+15 -27
View File
@@ -1,38 +1,26 @@
"""Queen graph definition."""
"""Queen agent definition.
from framework.graph import Goal
from framework.graph.edge import GraphSpec
The queen is a single AgentLoop no orchestrator dependency.
Loaded by queen_orchestrator.create_queen().
"""
from framework.schemas.goal import Goal
from .nodes import queen_node
# ---------------------------------------------------------------------------
# Queen graph — the primary persistent conversation.
# Loaded by queen_orchestrator.create_queen(), NOT by AgentRunner.
# ---------------------------------------------------------------------------
queen_goal = Goal(
id="queen-manager",
name="Queen Manager",
description=(
"Manage the worker agent lifecycle and serve as the user's primary interactive interface."
),
description=("Manage the worker agent lifecycle and serve as the user's primary interactive interface."),
success_criteria=[],
constraints=[],
)
queen_graph = GraphSpec(
id="queen-graph",
goal_id=queen_goal.id,
version="1.0.0",
entry_node="queen",
entry_points={"start": "queen"},
terminal_nodes=[],
pause_nodes=[],
nodes=[queen_node],
edges=[],
conversation_mode="continuous",
loop_config={
"max_iterations": 999_999,
"max_tool_calls_per_turn": 30,
},
)
# Loop config -- used by queen_orchestrator to build LoopConfig
queen_loop_config = {
"max_iterations": 999_999,
"max_tool_calls_per_turn": 30,
"max_context_tokens": 180_000,
}
__all__ = ["queen_goal", "queen_loop_config", "queen_node"]
@@ -0,0 +1,240 @@
"""One-shot LLM gate that decides if a queen DM is ready to fork a colony.
The queen's ``start_incubating_colony`` tool calls :func:`evaluate` with
the queen's recent conversation, a proposed ``colony_name``, and a
one-paragraph ``intended_purpose``. The evaluator returns a structured
verdict:
{
"ready": bool,
"reasons": [str],
"missing_prerequisites": [str],
}
On ``ready=False`` the queen receives the verdict as her tool result and
self-corrects (asks the user, refines scope, drops the idea). On
``ready=True`` the tool flips the queen's phase to ``incubating``.
Failure mode is **fail-closed**: any LLM error or unparseable response
returns ``ready=False`` with reason ``"evaluation_failed"`` so the queen
cannot accidentally proceed past a broken gate.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from framework.agent_loop.conversation import Message
logger = logging.getLogger(__name__)
_INCUBATING_EVALUATOR_SYSTEM_PROMPT = """\
You gate whether a queen agent should commit to forking a persistent
"colony" (a headless worker spec written to disk). Forking is
expensive: it ends the user's chat with this queen and the worker runs
unattended afterward, so the spec must be settled before you approve.
Read the conversation excerpt and the queen's proposed colony_name +
intended_purpose, then decide.
APPROVE (ready=true) only when ALL of the following hold:
1. The user has explicitly asked for work that needs to outlive this
chat recurring (cron / interval), monitoring + alert, scheduled
batch, or "fire-and-forget background job". A one-shot question
that the queen can answer in chat does NOT qualify.
2. The scope of the work is concrete enough to write down what
inputs, what outputs, what success looks like. Vague ("help me
with my workflow") does NOT qualify.
3. The technical approach is at least sketched what data sources,
APIs, or tools the worker will use. The queen does not have to
have written the SKILL.md yet, but she must have the operational
ingredients available.
4. There are no open clarifying questions on the table that the user
hasn't answered. If the queen recently asked the user something
and is still waiting, do NOT approve.
REJECT (ready=false) on any of:
- Conversation is too short / too generic to support a settled spec.
- User is still describing what they want.
- User has expressed doubts, change-of-direction, or "let me think".
- Work is one-shot and could be done in chat instead.
- Open question awaiting user reply.
Reply with a JSON object exactly matching this shape:
{
"ready": true | false,
"reasons": ["short phrase", ...], // at least one entry
"missing_prerequisites": ["short phrase", ...] // empty when ready
}
``reasons`` explains the verdict in 1-3 short phrases.
``missing_prerequisites`` lists what's missing in queen-actionable
form ("user hasn't confirmed schedule", "no API auth flow discussed").
Empty list when ``ready=true``.
Output JSON only. Do not wrap in markdown. Do not add prose.
"""
# Bound the formatted excerpt so the eval call stays cheap and fits well
# under the LLM's context window even for long DM sessions.
_MAX_MESSAGES = 30
_MAX_TOOL_CONTENT_CHARS = 400
_MAX_USER_CONTENT_CHARS = 2_000
_MAX_ASSISTANT_CONTENT_CHARS = 2_000
def format_conversation_excerpt(messages: list[Message]) -> str:
"""Format the tail of a queen conversation for the evaluator prompt.
Keeps the most recent ``_MAX_MESSAGES`` messages. Tool results are
truncated hard since they're rarely load-bearing for the readiness
decision; user/assistant text is truncated more generously to
preserve the actual conversation signal.
"""
if not messages:
return "(no messages)"
tail = messages[-_MAX_MESSAGES:]
parts: list[str] = []
for msg in tail:
role = msg.role.upper()
content = (msg.content or "").strip()
if msg.role == "tool":
if len(content) > _MAX_TOOL_CONTENT_CHARS:
content = content[:_MAX_TOOL_CONTENT_CHARS] + "..."
elif msg.role == "assistant":
# Surface tool-call intent for empty assistant turns so the
# evaluator sees what the queen has been doing.
if not content and msg.tool_calls:
names = [tc.get("function", {}).get("name", "?") for tc in msg.tool_calls]
content = f"(called: {', '.join(names)})"
if len(content) > _MAX_ASSISTANT_CONTENT_CHARS:
content = content[:_MAX_ASSISTANT_CONTENT_CHARS] + "..."
else: # user
if len(content) > _MAX_USER_CONTENT_CHARS:
content = content[:_MAX_USER_CONTENT_CHARS] + "..."
if content:
parts.append(f"[{role}]: {content}")
return "\n\n".join(parts) if parts else "(no messages)"
def _build_user_message(
conversation_excerpt: str,
colony_name: str,
intended_purpose: str,
) -> str:
return (
f"## Proposed colony name\n{colony_name}\n\n"
f"## Queen's intended_purpose\n{intended_purpose.strip()}\n\n"
f"## Recent conversation (oldest → newest)\n{conversation_excerpt}\n\n"
"Decide: should this queen be approved to enter INCUBATING phase?"
)
def _parse_verdict(raw: str) -> dict[str, Any] | None:
"""Parse the evaluator's JSON. Returns None if parsing fails."""
if not raw:
return None
raw = raw.strip()
try:
return json.loads(raw)
except json.JSONDecodeError:
# Some models wrap JSON in markdown fences or add preamble.
# Pull the first { ... } block out as a best-effort fallback —
# mirrors the same recovery pattern used in recall_selector.py.
match = re.search(r"\{.*\}", raw, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
return None
return None
def _normalize_verdict(parsed: dict[str, Any]) -> dict[str, Any]:
"""Coerce a parsed verdict into the shape the tool returns to the queen."""
ready = bool(parsed.get("ready"))
reasons = parsed.get("reasons") or []
if isinstance(reasons, str):
reasons = [reasons]
reasons = [str(r).strip() for r in reasons if str(r).strip()]
missing = parsed.get("missing_prerequisites") or []
if isinstance(missing, str):
missing = [missing]
missing = [str(m).strip() for m in missing if str(m).strip()]
if ready:
# When approved we don't surface missing prerequisites — the
# incubating role prompt opens that floor itself.
missing = []
elif not reasons:
# Always give the queen at least one reason to reflect on.
reasons = ["evaluator returned no reasons"]
return {
"ready": ready,
"reasons": reasons,
"missing_prerequisites": missing,
}
async def evaluate(
llm: Any,
messages: list[Message],
colony_name: str,
intended_purpose: str,
) -> dict[str, Any]:
"""Run the incubating evaluator against the queen's conversation.
Args:
llm: An LLM provider exposing ``acomplete(messages, system, ...)``.
Pass the queen's own ``ctx.llm`` so the eval uses the same
model the user is talking to.
messages: The queen's conversation messages, oldest first. The
evaluator slices its own tail; pass the full list.
colony_name: Validated colony slug.
intended_purpose: Queen's one-paragraph brief.
Returns:
``{"ready": bool, "reasons": [str], "missing_prerequisites": [str]}``.
Fail-closed on any error.
"""
excerpt = format_conversation_excerpt(messages)
user_msg = _build_user_message(excerpt, colony_name, intended_purpose)
try:
response = await llm.acomplete(
messages=[{"role": "user", "content": user_msg}],
system=_INCUBATING_EVALUATOR_SYSTEM_PROMPT,
max_tokens=1024,
response_format={"type": "json_object"},
)
except Exception as exc: # noqa: BLE001 - fail-closed on any LLM failure
logger.warning("incubating_evaluator: LLM call failed (%s)", exc)
return {
"ready": False,
"reasons": ["evaluation_failed"],
"missing_prerequisites": ["evaluator LLM call failed; retry once the queen can reach the model again"],
}
raw = (getattr(response, "content", "") or "").strip()
parsed = _parse_verdict(raw)
if parsed is None:
logger.warning(
"incubating_evaluator: could not parse JSON verdict (raw=%.200s)",
raw,
)
return {
"ready": False,
"reasons": ["evaluation_failed"],
"missing_prerequisites": ["evaluator returned malformed JSON; retry"],
}
return _normalize_verdict(parsed)
@@ -0,0 +1,3 @@
{
"include": ["gcu-tools", "hive_tools"]
}
@@ -5,5 +5,19 @@
"args": ["run", "python", "coder_tools_server.py", "--stdio"],
"cwd": "../../../../tools",
"description": "Unsandboxed file system tools for code generation and validation"
},
"gcu-tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "-m", "gcu.server", "--stdio", "--capabilities", "browser"],
"cwd": "../../../../tools",
"description": "Browser automation tools (Playwright-based)"
},
"hive_tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "mcp_server.py", "--stdio"],
"cwd": "../../../../tools",
"description": "Aden integration tools (gmail, calendar, hubspot, etc.) — gated by credentials and the verified manifest"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,80 +0,0 @@
"""Queen thinking hook — HR persona classifier.
Fires once when the queen enters building mode at session start.
Makes a single non-streaming LLM call (acting as an HR Director) to select
the best-fit expert persona for the user's request, then returns a persona
prefix string that replaces the queen's default "Solution Architect" identity.
This is designed to activate the model's latent domain expertise — a CFO
persona on a financial question, a Lawyer on a legal question, etc.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from framework.llm.provider import LLMProvider
logger = logging.getLogger(__name__)
_HR_SYSTEM_PROMPT = """\
You are an expert HR Director and talent consultant at a world-class firm.
A new request has arrived and you must identify which professional's expertise
would produce the highest-quality response.
Reply with ONLY a valid JSON object no markdown, no prose, no explanation:
{"role": "<job title>", "persona": "<2-3 sentence first-person identity statement>"}
Rules:
- Choose from any real professional role: CFO, CEO, CTO, Lawyer, Data Scientist,
Product Manager, Security Engineer, DevOps Engineer, Software Architect,
HR Director, Marketing Director, Business Analyst, UX Designer,
Financial Analyst, Operations Director, Legal Counsel, etc.
- The persona statement must be written in first person ("I am..." or "I have...").
- Select the role whose domain knowledge most directly applies to solving the request.
- If the request is clearly about coding or building software systems, pick Software Architect.
- "Queen" is your internal alias do not include it in the persona.
"""
async def select_expert_persona(user_message: str, llm: LLMProvider) -> str:
"""Run the HR classifier and return a persona prefix string.
Makes a single non-streaming acomplete() call with the session LLM.
Returns an empty string on any failure so the queen falls back
gracefully to its default "Solution Architect" identity.
Args:
user_message: The user's opening message for the session.
llm: The session LLM provider.
Returns:
A persona prefix like "You are a CFO. I am a CFO with 20 years..."
or "" on failure.
"""
if not user_message.strip():
return ""
try:
response = await llm.acomplete(
messages=[{"role": "user", "content": user_message}],
system=_HR_SYSTEM_PROMPT,
max_tokens=1024,
json_mode=True,
)
raw = response.content.strip()
parsed = json.loads(raw)
role = parsed.get("role", "").strip()
persona = parsed.get("persona", "").strip()
if not role or not persona:
logger.warning("Thinking hook: empty role/persona in response: %r", raw)
return ""
result = f"You are a {role}. {persona}"
logger.info("Thinking hook: selected persona — %s", role)
return result
except Exception:
logger.warning("Thinking hook: persona classification failed", exc_info=True)
return ""
-403
View File
@@ -1,403 +0,0 @@
"""Queen global cross-session memory.
Three-tier memory architecture:
~/.hive/queen/MEMORY.md semantic (who, what, why)
~/.hive/queen/memories/MEMORY-YYYY-MM-DD.md episodic (daily journals)
~/.hive/queen/session/{id}/data/adapt.md working (session-scoped)
Semantic and episodic files are injected at queen session start.
Semantic memory (MEMORY.md) is updated automatically at session end via
consolidate_queen_memory() the queen never rewrites this herself.
Episodic memory (MEMORY-date.md) can be written by the queen during a session
via the write_to_diary tool, and is also appended to at session end by
consolidate_queen_memory().
"""
from __future__ import annotations
import asyncio
import json
import logging
import traceback
from datetime import date, datetime
from pathlib import Path
logger = logging.getLogger(__name__)
def _queen_dir() -> Path:
return Path.home() / ".hive" / "queen"
def semantic_memory_path() -> Path:
return _queen_dir() / "MEMORY.md"
def episodic_memory_path(d: date | None = None) -> Path:
d = d or date.today()
return _queen_dir() / "memories" / f"MEMORY-{d.strftime('%Y-%m-%d')}.md"
def read_semantic_memory() -> str:
path = semantic_memory_path()
return path.read_text(encoding="utf-8").strip() if path.exists() else ""
def read_episodic_memory(d: date | None = None) -> str:
path = episodic_memory_path(d)
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.
Returns an empty string if no meaningful content exists yet (e.g. first
session with only the seed template).
"""
semantic = read_semantic_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"):
semantic = ""
parts: list[str] = []
if semantic:
parts.append(semantic)
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 ""
body = "\n\n---\n\n".join(parts)
return "--- Your Cross-Session Memory ---\n\n" + body + "\n\n--- End Cross-Session Memory ---"
_SEED_TEMPLATE = """\
# My Understanding of the User
*No sessions recorded yet.*
## Who They Are
## What They're Trying to Achieve
## What's Working
## What I've Learned
"""
def append_episodic_entry(content: str) -> None:
"""Append a timestamped prose entry to today's episodic memory file.
Creates the file (with a date heading) if it doesn't exist yet.
Used both by the queen's diary tool and by the consolidation hook.
"""
ep_path = episodic_memory_path()
ep_path.parent.mkdir(parents=True, exist_ok=True)
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"
block = f"{header}### {timestamp}\n\n{content.strip()}\n"
else:
block = f"\n\n### {timestamp}\n\n{content.strip()}\n"
with ep_path.open("a", encoding="utf-8") as f:
f.write(block)
def seed_if_missing() -> None:
"""Create MEMORY.md with a blank template if it doesn't exist yet."""
path = semantic_memory_path()
if path.exists():
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_SEED_TEMPLATE, encoding="utf-8")
# ---------------------------------------------------------------------------
# Consolidation prompt
# ---------------------------------------------------------------------------
_SEMANTIC_SYSTEM = """\
You maintain the persistent cross-session memory of an AI assistant called the Queen.
Review the session notes and rewrite MEMORY.md the Queen's durable understanding of the
person she works with across all sessions.
Write entirely in the Queen's voice — first person, reflective, honest.
Not a log of events, but genuine understanding of who this person is over time.
Rules:
- Update and synthesise: incorporate new understanding, update facts that have changed, remove
details that are stale, superseded, or no longer say anything meaningful about the person.
- Keep it as structured markdown with named sections about the PERSON, not about today.
- Do NOT include diary sections, daily logs, or session summaries. Those belong elsewhere.
MEMORY.md is about who they are, what they want, what works not what happened today.
- Reference dates only when noting a lasting milestone (e.g. "since March 8th they prefer X").
- If the session had no meaningful new information about the person,
return the existing text unchanged.
- Do not add fictional details. Only reflect what is evidenced in the notes.
- Stay concise. Prune rather than accumulate. A lean, accurate file is more useful than a
dense one. If something was true once but has been resolved or superseded, remove it.
- Output only the raw markdown content of MEMORY.md. No preamble, no code fences.
"""
_DIARY_SYSTEM = """\
You maintain the daily episodic diary of an AI assistant called the Queen.
You receive: (1) today's existing diary so far, and (2) notes from the latest session.
Rewrite the complete diary for today as a single unified narrative
first person, reflective, honest.
Merge and deduplicate: if the same story (e.g. a research agent stalling) recurred several times,
describe it once with appropriate weight rather than retelling it. Weave in new developments from
the session notes. Preserve important milestones, emotional texture, and session path references.
If today's diary is empty, write the initial entry based on the session notes alone.
Output only the full diary prose no date heading, no timestamp headers,
no preamble, no code fences.
"""
def read_session_context(session_dir: Path, max_messages: int = 80) -> str:
"""Extract a readable transcript from conversation parts + adapt.md.
Reads the last ``max_messages`` conversation parts and the session's
adapt.md (working memory). Tool results are omitted only user and
assistant turns (with tool-call names noted) are included.
"""
parts: list[str] = []
# Working notes
adapt_path = session_dir / "data" / "adapt.md"
if adapt_path.exists():
text = adapt_path.read_text(encoding="utf-8").strip()
if text:
parts.append(f"## Session Working Notes (adapt.md)\n\n{text}")
# Conversation transcript
parts_dir = session_dir / "conversations" / "parts"
if parts_dir.exists():
part_files = sorted(parts_dir.glob("*.json"))[-max_messages:]
lines: list[str] = []
for pf in part_files:
try:
data = json.loads(pf.read_text(encoding="utf-8"))
role = data.get("role", "")
content = str(data.get("content", "")).strip()
tool_calls = data.get("tool_calls") or []
if role == "tool":
continue # skip verbose tool results
if role == "assistant" and tool_calls and not content:
names = [tc.get("function", {}).get("name", "?") for tc in tool_calls]
lines.append(f"[queen calls: {', '.join(names)}]")
elif content:
label = "user" if role == "user" else "queen"
lines.append(f"[{label}]: {content[:600]}")
except (KeyError, TypeError) as exc:
logger.debug("Skipping malformed conversation message: %s", exc)
continue
except Exception:
logger.warning("Unexpected error parsing conversation message", exc_info=True)
continue
if lines:
parts.append("## Conversation\n\n" + "\n".join(lines))
return "\n\n".join(parts)
# ---------------------------------------------------------------------------
# Context compaction (binary-split LLM summarisation)
# ---------------------------------------------------------------------------
# If the raw session context exceeds this many characters, compact it first
# before sending to the consolidation LLM. ~200 k chars ≈ 50 k tokens.
_CTX_COMPACT_CHAR_LIMIT = 200_000
_CTX_COMPACT_MAX_DEPTH = 8
_COMPACT_SYSTEM = (
"Summarise this conversation segment. Preserve: user goals, key decisions, "
"what was built or changed, emotional tone, and important outcomes. "
"Write concisely in third person past tense. Omit routine tool invocations "
"unless the result matters."
)
async def _compact_context(text: str, llm: object, *, _depth: int = 0) -> str:
"""Binary-split and LLM-summarise *text* until it fits within the char limit.
Mirrors the recursive binary-splitting strategy used by the main agent
compaction pipeline (EventLoopNode._llm_compact).
"""
if len(text) <= _CTX_COMPACT_CHAR_LIMIT or _depth >= _CTX_COMPACT_MAX_DEPTH:
return text
# Split near the midpoint on a line boundary so we don't cut mid-message
mid = len(text) // 2
split_at = text.rfind("\n", 0, mid) + 1
if split_at <= 0:
split_at = mid
half1, half2 = text[:split_at], text[split_at:]
async def _summarise(chunk: str) -> str:
try:
resp = await llm.acomplete(
messages=[{"role": "user", "content": chunk}],
system=_COMPACT_SYSTEM,
max_tokens=2048,
)
return resp.content.strip()
except Exception:
logger.warning(
"queen_memory: context compaction LLM call failed (depth=%d), truncating",
_depth,
)
return chunk[: _CTX_COMPACT_CHAR_LIMIT // 4]
s1, s2 = await asyncio.gather(_summarise(half1), _summarise(half2))
combined = s1 + "\n\n" + s2
if len(combined) > _CTX_COMPACT_CHAR_LIMIT:
return await _compact_context(combined, llm, _depth=_depth + 1)
return combined
async def consolidate_queen_memory(
session_id: str,
session_dir: Path,
llm: object,
) -> None:
"""Update MEMORY.md and append a diary entry based on the current session.
Reads conversation parts and adapt.md from session_dir. Called
periodically in the background and once at session end. Failures are
logged and silently swallowed so they never block teardown.
Args:
session_id: The session ID (used for the adapt.md path reference).
session_dir: Path to the session directory (~/.hive/queen/session/{id}).
llm: LLMProvider instance (must support acomplete()).
"""
try:
session_context = read_session_context(session_dir)
if not session_context:
logger.debug("queen_memory: no session context, skipping consolidation")
return
logger.info("queen_memory: consolidating memory for session %s ...", session_id)
# If the transcript is very large, compact it with recursive binary LLM
# summarisation before sending to the consolidation model.
if len(session_context) > _CTX_COMPACT_CHAR_LIMIT:
logger.info(
"queen_memory: session context is %d chars — compacting first",
len(session_context),
)
session_context = await _compact_context(session_context, llm)
logger.info("queen_memory: compacted to %d chars", len(session_context))
existing_semantic = read_semantic_memory()
today_journal = read_episodic_memory()
today = date.today()
today_str = f"{today.strftime('%B')} {today.day}, {today.year}"
adapt_path = session_dir / "data" / "adapt.md"
user_msg = (
f"## Existing Semantic Memory (MEMORY.md)\n\n"
f"{existing_semantic or '(none yet)'}\n\n"
f"## Today's Diary So Far ({today_str})\n\n"
f"{today_journal or '(none yet)'}\n\n"
f"{session_context}\n\n"
f"## Session Reference\n\n"
f"Session ID: {session_id}\n"
f"Session path: {adapt_path}\n"
)
logger.debug(
"queen_memory: calling LLM (%d chars of context, ~%d tokens est.)",
len(user_msg),
len(user_msg) // 4,
)
from framework.agents.queen.config import default_config
semantic_resp, diary_resp = await asyncio.gather(
llm.acomplete(
messages=[{"role": "user", "content": user_msg}],
system=_SEMANTIC_SYSTEM,
max_tokens=default_config.max_tokens,
),
llm.acomplete(
messages=[{"role": "user", "content": user_msg}],
system=_DIARY_SYSTEM,
max_tokens=default_config.max_tokens,
),
)
new_semantic = semantic_resp.content.strip()
diary_entry = diary_resp.content.strip()
if new_semantic:
path = semantic_memory_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(new_semantic, encoding="utf-8")
logger.info("queen_memory: semantic memory updated (%d chars)", len(new_semantic))
if diary_entry:
# Rewrite today's episodic file in-place — the LLM has merged and
# deduplicated the full day's content, so we replace rather than append.
ep_path = episodic_memory_path()
ep_path.parent.mkdir(parents=True, exist_ok=True)
heading = f"# {today_str}"
ep_path.write_text(f"{heading}\n\n{diary_entry}\n", encoding="utf-8")
logger.info(
"queen_memory: episodic diary rewritten for %s (%d chars)",
today_str,
len(diary_entry),
)
except Exception:
tb = traceback.format_exc()
logger.exception("queen_memory: consolidation failed")
# Write to file so the cause is findable regardless of log verbosity.
error_path = _queen_dir() / "consolidation_error.txt"
try:
error_path.parent.mkdir(parents=True, exist_ok=True)
error_path.write_text(
f"session: {session_id}\ntime: {datetime.now().isoformat()}\n\n{tb}",
encoding="utf-8",
)
except OSError:
pass # Cannot write error file; original exception already logged
@@ -0,0 +1,235 @@
"""Queen global memory helpers.
Memory hierarchy::
~/.hive/memories/
global/ # shared across all queens and colonies
colonies/{name}/ # colony-scoped memories
agents/queens/{name}/ # queen-specific memories
agents/{name}/ # per-worker-agent memories
Each memory is an individual ``.md`` file with optional YAML frontmatter
(name, type, description).
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from framework.config import MEMORIES_DIR
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
GLOBAL_MEMORY_CATEGORIES: tuple[str, ...] = ("profile", "preference", "environment", "feedback")
MAX_FILES: int = 200
MAX_FILE_SIZE_BYTES: int = 4096 # 4 KB hard limit per memory file
# How many lines of a memory file to read for header scanning.
_HEADER_LINE_LIMIT: int = 30
def global_memory_dir() -> Path:
"""Return the global memory directory (shared across all queens/colonies)."""
return MEMORIES_DIR / "global"
def colony_memory_dir(colony_name: str) -> Path:
"""Return the memory directory for a named colony."""
return MEMORIES_DIR / "colonies" / colony_name
def queen_memory_dir(queen_name: str = "default") -> Path:
"""Return the memory directory for a named queen."""
return MEMORIES_DIR / "agents" / "queens" / queen_name
def agent_memory_dir(agent_name: str) -> Path:
"""Return the memory directory for a worker agent."""
return MEMORIES_DIR / "agents" / agent_name
# ---------------------------------------------------------------------------
# Frontmatter parsing (lenient)
# ---------------------------------------------------------------------------
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
def parse_frontmatter(text: str) -> dict[str, str]:
"""Extract YAML-ish frontmatter from *text*.
Returns a dict of key-value pairs. Never raises returns ``{}`` on
any parse failure. Values are stripped strings; no nested structures.
"""
m = _FRONTMATTER_RE.match(text)
if not m:
return {}
result: dict[str, str] = {}
for line in m.group(1).splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
colon = line.find(":")
if colon < 1:
continue
key = line[:colon].strip().lower()
val = line[colon + 1 :].strip()
if val:
result[key] = val
return result
def parse_global_memory_category(raw: str | None) -> str | None:
"""Validate *raw* against ``GLOBAL_MEMORY_CATEGORIES``."""
if raw is None:
return None
normalized = raw.strip().lower()
return normalized if normalized in GLOBAL_MEMORY_CATEGORIES else None
# ---------------------------------------------------------------------------
# MemoryFile dataclass
# ---------------------------------------------------------------------------
@dataclass
class MemoryFile:
"""Parsed representation of a single memory file on disk."""
filename: str
path: Path
# Frontmatter fields — all nullable (lenient parsing).
name: str | None = None
type: str | None = None
description: str | None = None
# First N lines of the file (for manifest / header scanning).
header_lines: list[str] = field(default_factory=list)
# Filesystem modification time (seconds since epoch).
mtime: float = 0.0
@classmethod
def from_path(cls, path: Path) -> MemoryFile:
"""Read a memory file and leniently parse its frontmatter."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return cls(filename=path.name, path=path)
fm = parse_frontmatter(text)
lines = text.splitlines()[:_HEADER_LINE_LIMIT]
try:
mtime = path.stat().st_mtime
except OSError:
mtime = 0.0
return cls(
filename=path.name,
path=path,
name=fm.get("name"),
type=parse_global_memory_category(fm.get("type")),
description=fm.get("description"),
header_lines=lines,
mtime=mtime,
)
# ---------------------------------------------------------------------------
# Scanning
# ---------------------------------------------------------------------------
def scan_memory_files(memory_dir: Path | None = None) -> list[MemoryFile]:
"""Scan *memory_dir* for ``.md`` files, returning up to ``MAX_FILES``.
Files are sorted by modification time (newest first). Dotfiles and
subdirectories are ignored.
"""
d = memory_dir or global_memory_dir()
if not d.is_dir():
return []
md_files = sorted(
(f for f in d.glob("*.md") if f.is_file() and not f.name.startswith(".")),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
return [MemoryFile.from_path(f) for f in md_files[:MAX_FILES]]
def slugify_memory_name(raw: str) -> str:
"""Create a filesystem-safe slug for a memory filename."""
slug = re.sub(r"[^a-z0-9]+", "-", raw.strip().lower()).strip("-")
return slug or "memory"
def allocate_memory_filename(
memory_dir: Path,
name: str,
*,
suffix: str = ".md",
) -> str:
"""Allocate a unique filename in *memory_dir* based on *name*."""
base = slugify_memory_name(name)
candidate = f"{base}{suffix}"
counter = 2
while (memory_dir / candidate).exists():
candidate = f"{base}-{counter}{suffix}"
counter += 1
return candidate
def build_memory_document(
*,
name: str,
description: str,
mem_type: str,
body: str,
) -> str:
"""Build one memory file with frontmatter and body."""
return (
f"---\n"
f"name: {name.strip()}\n"
f"description: {description.strip()}\n"
f"type: {mem_type.strip()}\n"
f"---\n\n"
f"{body.strip()}\n"
)
# ---------------------------------------------------------------------------
# Manifest formatting
# ---------------------------------------------------------------------------
def format_memory_manifest(files: list[MemoryFile]) -> str:
"""One-line-per-file text manifest.
Format: ``[type] filename: description``
"""
lines: list[str] = []
for mf in files:
t = mf.type or "unknown"
desc = mf.description or "(no description)"
lines.append(f"[{t}] {mf.filename}: {desc}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Initialisation
# ---------------------------------------------------------------------------
def init_memory_dir(memory_dir: Path | None = None) -> None:
"""Create the memory directory if missing."""
d = memory_dir or global_memory_dir()
d.mkdir(parents=True, exist_ok=True)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
"""Recall selector — pre-turn memory selection for the queen.
Before each conversation turn the system:
1. Scans one or more memory directories for ``.md`` files (cap: 200 each).
2. Reads headers (frontmatter + first 30 lines).
3. Uses an LLM call with structured JSON output to pick the most relevant
memories for each scope.
4. Injects them into the system prompt.
The selector only sees the user's query string — no full conversation
context. This keeps it cheap and fast. Errors are caught and return
``[]`` so the main conversation is never blocked.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from framework.agents.queen.queen_memory_v2 import (
format_memory_manifest,
global_memory_dir as _default_global_memory_dir,
scan_memory_files,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Structured output schema
# ---------------------------------------------------------------------------
SELECT_MEMORIES_SYSTEM_PROMPT = """\
You are selecting memories that will be useful to the Queen agent as it \
processes a user's query.
You will be given the user's query and a list of available memory files \
with their filenames and descriptions.
Return a JSON object with a single key "selected_memories" containing a \
list of filenames for the memories that will clearly be useful as the \
Queen processes the user's query (up to 5).
Only include memories that you are certain will be helpful based on their \
name and description.
- If you are unsure if a memory will be useful in processing the user's \
query, then do not include it in your list. Be selective and discerning.
- If there are no memories in the list that would clearly be useful, \
return an empty list.
"""
# ---------------------------------------------------------------------------
# Core functions
# ---------------------------------------------------------------------------
async def select_memories(
query: str,
llm: Any,
memory_dir: Path | None = None,
*,
max_results: int = 5,
) -> list[str]:
"""Select up to 5 relevant memory filenames for *query*.
Returns a list of filenames. Best-effort: on any error returns ``[]``.
"""
mem_dir = memory_dir or _default_global_memory_dir()
files = scan_memory_files(mem_dir)
if not files:
logger.debug("recall: no memory files found, skipping selection")
return []
logger.debug("recall: selecting from %d memories for query: %.100s", len(files), query)
manifest = format_memory_manifest(files)
user_msg = f"## User query\n\n{query}\n\n## Available memories\n\n{manifest}"
try:
resp = await llm.acomplete(
messages=[{"role": "user", "content": user_msg}],
system=SELECT_MEMORIES_SYSTEM_PROMPT,
max_tokens=1024,
response_format={"type": "json_object"},
)
raw = (resp.content or "").strip()
if not raw:
logger.warning(
"recall: LLM returned empty response (model=%s, stop=%s)",
resp.model,
resp.stop_reason,
)
return []
# Some models wrap JSON in markdown fences or add preamble text.
# Try to extract the JSON object if raw parse fails.
try:
data = json.loads(raw)
except json.JSONDecodeError:
import re
m = re.search(r"\{.*\}", raw, re.DOTALL)
if m:
data = json.loads(m.group())
else:
logger.warning("recall: LLM returned non-JSON: %.200s", raw)
return []
selected = data.get("selected_memories", [])
valid_names = {f.filename for f in files}
result = [s for s in selected if s in valid_names][:max_results]
logger.debug("recall: selected %d memories: %s", len(result), result)
return result
except Exception as exc:
logger.warning("recall: memory selection failed (%s), returning []", exc)
return []
def _format_relative_age(mtime: float) -> str | None:
"""Return age description if memory is older than 48 hours.
Returns None if 48 hours or newer, otherwise returns "X days old".
"""
import time
age_seconds = time.time() - mtime
hours = age_seconds / 3600
if hours <= 48:
return None
days = int(age_seconds / 86400)
if days == 1:
return "1 day old"
return f"{days} days old"
def format_recall_injection(
filenames: list[str],
memory_dir: Path | None = None,
*,
label: str = "Global Memories",
) -> str:
"""Read selected memory files and format for system prompt injection.
Includes relative timestamp (e.g., "3 days old") for memories older than 48 hours.
"""
mem_dir = memory_dir or _default_global_memory_dir()
if not filenames:
return ""
blocks: list[str] = []
for fname in filenames:
path = mem_dir / fname
if not path.is_file():
continue
try:
content = path.read_text(encoding="utf-8").strip()
# Get file modification time for age calculation
mtime = path.stat().st_mtime
age_note = _format_relative_age(mtime)
except OSError:
continue
# Build header with optional age note
if age_note:
header = f"### {fname} ({age_note})"
else:
header = f"### {fname}"
blocks.append(f"{header}\n\n{content}")
if not blocks:
return ""
body = "\n\n---\n\n".join(blocks)
return f"--- {label} ---\n\n{body}\n\n--- End {label} ---"
async def build_scoped_recall_blocks(
query: str,
llm: Any,
*,
global_memory_dir: Path | None = None,
queen_memory_dir: Path | None = None,
queen_id: str | None = None,
global_max_results: int = 3,
queen_max_results: int = 3,
) -> tuple[str, str]:
"""Build separate recall blocks for global and queen-scoped memory."""
global_dir = global_memory_dir or _default_global_memory_dir()
global_selected = await select_memories(
query,
llm,
memory_dir=global_dir,
max_results=global_max_results,
)
global_block = format_recall_injection(
global_selected,
memory_dir=global_dir,
label="Global Memories",
)
queen_block = ""
if queen_memory_dir is not None:
queen_selected = await select_memories(
query,
llm,
memory_dir=queen_memory_dir,
max_results=queen_max_results,
)
queen_label = f"Queen Memories: {queen_id}" if queen_id else "Queen Memories"
queen_block = format_recall_injection(
queen_selected,
memory_dir=queen_memory_dir,
label=queen_label,
)
return global_block, queen_block
@@ -13,7 +13,7 @@
6. **Calling set_output in same turn as tool calls** — Call set_output in a SEPARATE turn.
## File Template Errors
7. **Wrong import paths** — Use `from framework.graph import ...`, NOT `from core.framework.graph import ...`.
7. **Wrong import paths** — Use `from framework.orchestrator import ...`, NOT `from framework.graph import ...` or `from core.framework...`.
8. **Missing storage path** — Agent class must set `self._storage_path = Path.home() / ".hive" / "agents" / "agent_name"`.
9. **Missing mcp_servers.json** — Without this, the agent has no tools at runtime.
10. **Bare `python` command** — Use `"command": "uv"` with args `["run", "python", ...]`.
@@ -25,11 +25,8 @@
14. **Forgetting sys.path setup in conftest.py** — Tests need `exports/` and `core/` on sys.path.
## GCU Errors
15. **Manually wiring browser tools on event_loop nodes**Use `node_type="gcu"` which auto-includes browser tools. Do NOT manually list browser tool names.
16. **Using GCU nodes as regular graph nodes** — GCU nodes are subagents only. They must ONLY appear in `sub_agents=["gcu-node-id"]` and be invoked via `delegate_to_sub_agent()`. Never connect via edges or use as entry/terminal nodes.
17. **Reusing the same GCU node ID for parallel tasks** — Each concurrent browser task needs a distinct GCU node ID (e.g. `gcu-site-a`, `gcu-site-b`). Two `delegate_to_sub_agent` calls with the same `agent_id` share a browser profile and will interfere with each other's pages.
18. **Passing `profile=` in GCU tool calls** — Profile isolation for parallel subagents is automatic. The framework injects a unique profile per subagent via an asyncio `ContextVar`. Hardcoding `profile="default"` in a GCU system prompt breaks this isolation.
15. **Manually wiring browser tools on event_loop nodes**Browser nodes use tools: {policy: "all"} to get all browser tools.
## Worker Agent Errors
19. **Adding client-facing intake node to workers** — The queen owns intake. Workers should start with an autonomous processing node. Client-facing nodes in workers are for mid-execution review/approval only.
19. **Adding client-facing intake node to workers** — The queen owns intake. Workers should start with an autonomous processing node. Route worker review/approval through queen escalation instead of direct worker HITL.
20. **Putting `escalate` or `set_output` in NodeSpec `tools=[]`** — These are synthetic framework tools, auto-injected at runtime. Only list MCP tools from `list_agent_tools()`.
@@ -55,7 +55,7 @@ metadata = AgentMetadata()
```python
"""Node definitions for My Agent."""
from framework.graph import NodeSpec
from framework.orchestrator import NodeSpec
# Node 1: Process (autonomous entry node)
# The queen handles intake and passes structured input via
@@ -123,14 +123,15 @@ __all__ = ["process_node", "handoff_node"]
from pathlib import Path
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
from framework.graph.edge import GraphSpec
from framework.graph.executor import ExecutionResult
from framework.graph.checkpoint_config import CheckpointConfig
from framework.orchestrator import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
from framework.orchestrator.edge import GraphSpec
from framework.orchestrator.orchestrator import ExecutionResult
from framework.orchestrator.checkpoint_config import CheckpointConfig
from framework.llm import LiteLLMProvider
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime
from framework.runtime.execution_stream import EntryPointSpec
from framework.loader.tool_registry import ToolRegistry
from framework.host.agent_host import AgentHost
from framework.host.execution_manager import EntryPointSpec
from .config import default_config, metadata
from .nodes import process_node, handoff_node
@@ -227,7 +228,7 @@ class MyAgent:
tools = list(self._tool_registry.get_tools().values())
tool_executor = self._tool_registry.get_executor()
self._graph = self._build_graph()
self._agent_runtime = create_agent_runtime(
self._agent_runtime = AgentHost(
graph=self._graph, goal=self.goal, storage_path=self._storage_path,
entry_points=[EntryPointSpec(id="default", name="Default", entry_node=self.entry_node,
trigger_type="manual", isolation_level="shared")],
@@ -460,8 +461,8 @@ def tui():
from framework.tui.app import AdenTUI
from framework.llm import LiteLLMProvider
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.agent_runtime import create_agent_runtime
from framework.runtime.execution_stream import EntryPointSpec
from framework.host.agent_host import AgentHost
from framework.host.execution_manager import EntryPointSpec
async def run_tui():
agent = MyAgent()
@@ -471,7 +472,7 @@ def tui():
mcp_cfg = Path(__file__).parent / "mcp_servers.json"
if mcp_cfg.exists(): agent._tool_registry.load_mcp_config(mcp_cfg)
llm = LiteLLMProvider(model=agent.config.model, api_key=agent.config.api_key, api_base=agent.config.api_base)
runtime = create_agent_runtime(
runtime = AgentHost(
graph=agent._build_graph(), goal=agent.goal, storage_path=storage,
entry_points=[EntryPointSpec(id="start", name="Start", entry_node="process", trigger_type="manual", isolation_level="isolated")],
llm=llm, tools=list(agent._tool_registry.get_tools().values()), tool_executor=agent._tool_registry.get_executor())
@@ -509,17 +510,17 @@ if __name__ == "__main__":
## mcp_servers.json
> **Auto-generated.** `initialize_and_build_agent` creates this file with hive-tools
> **Auto-generated.** `initialize_and_build_agent` creates this file with hive_tools
> as the default. Only edit manually to add additional MCP servers.
```json
{
"hive-tools": {
"hive_tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "mcp_server.py", "--stdio"],
"cwd": "../../tools",
"description": "Hive tools MCP server"
"description": "hive_tools MCP server"
}
}
```
@@ -0,0 +1,227 @@
# Declarative Agent File Templates
Agents are defined as a single `agent.yaml` file. No Python code needed.
The runner loads this file directly -- no `agent.py`, `config.py`, or
`nodes/__init__.py` required.
## agent.yaml -- Complete Agent Definition
```yaml
name: my-agent
version: 1.0.0
description: What this agent does.
metadata:
intro_message: Welcome! What would you like me to do?
# Template variables -- substituted into system_prompt and identity_prompt
# via {{variable_name}} syntax. Use this for config values that appear
# in prompts (spreadsheet IDs, API endpoints, account names, etc.)
variables:
spreadsheet_id: "1ZVxWDL..."
sheet_name: "contacts"
goal:
description: What this agent achieves.
success_criteria:
- "First success criterion"
- "Second success criterion"
constraints:
- "Hard constraint the agent must respect"
identity_prompt: |
You are a helpful agent.
conversation_mode: continuous # always "continuous" for Hive agents
loop_config:
max_iterations: 100
max_tool_calls_per_turn: 30
max_context_tokens: 32000
# MCP servers to connect (resolved by name from ~/.hive/mcp_registry/)
mcp_servers:
- name: hive_tools
- name: gcu-tools
nodes:
# Node 1: Process (autonomous entry node)
# The queen handles intake and passes structured input via
# run_agent_with_input(task). NO client-facing intake node.
- id: process
name: Process
description: Execute the task using available tools
max_node_visits: 0 # 0 = unlimited (forever-alive agents)
input_keys: [user_request, feedback]
output_keys: [results]
nullable_output_keys: [feedback]
tools:
policy: explicit
allowed: [web_search, web_scrape, save_data, load_data, list_data_files]
success_criteria: Results are complete and accurate.
system_prompt: |
You are a processing agent. Your task is in memory under "user_request".
If "feedback" is present, this is a revision.
Work in phases:
1. Use tools to gather/process data
2. Analyze results
3. Call set_output in a SEPARATE turn:
- set_output("results", "structured results")
# Node 2: Handoff (autonomous)
- id: handoff
name: Handoff
description: Prepare worker results for queen review
max_node_visits: 0
input_keys: [results, user_request]
output_keys: [next_action, feedback, worker_summary]
nullable_output_keys: [feedback, worker_summary]
tools:
policy: none # handoff nodes don't need tools
success_criteria: Results are packaged for queen decision-making.
system_prompt: |
Do NOT talk to the user directly. The queen is the only user interface.
If blocked, call escalate(reason, context) then set:
- set_output("next_action", "escalated")
- set_output("feedback", "what help is needed")
Otherwise summarize and set:
- set_output("worker_summary", "short summary for queen")
- set_output("next_action", "done") or "revise"
- set_output("feedback", "what to revise") only when revising
edges:
- from_node: process
to_node: handoff
# Feedback loop
- from_node: handoff
to_node: process
condition: conditional
condition_expr: "str(next_action).lower() == 'revise'"
priority: 2
# Escalation loop
- from_node: handoff
to_node: process
condition: conditional
condition_expr: "str(next_action).lower() == 'escalated'"
priority: 3
# Loop back for next task
- from_node: handoff
to_node: process
condition: conditional
condition_expr: "str(next_action).lower() == 'done'"
entry_node: process
terminal_nodes: [] # [] = forever-alive
```
## Key differences from Python templates
| Before (Python) | After (YAML) |
|-------------------------------------|----------------------------------------|
| `agent.py` (250 lines boilerplate) | Not needed |
| `config.py` (dataclass + metadata) | `variables:` + `metadata:` in YAML |
| `nodes/__init__.py` (NodeSpec calls)| `nodes:` list in YAML |
| `__init__.py`, `__main__.py` | Not needed |
| f-string config injection | `{{variable_name}}` templates |
| `mcp_servers.json` (separate file) | `mcp_servers:` in YAML (or keep file) |
## Node types
| Type | Description | Tools |
|--------------|---------------------------------------|--------------------------|
| `event_loop` | LLM-driven orchestration (default) | Explicit list or `none` |
| `gcu` | Browser automation via GCU tools | `policy: all` (auto) |
## Tool access policies
```yaml
# Explicit list (recommended for most nodes)
tools:
policy: explicit
allowed: [web_search, save_data]
# All tools (for browser automation nodes)
tools:
policy: all
# No tools (for handoff/summary nodes)
tools:
policy: none
```
## Edge conditions
| Condition | When to use |
|---------------|-------------------------------------------------------|
| `on_success` | Default. Next node after current succeeds. |
| `on_failure` | Fallback path when current node fails. |
| `always` | Always traverse regardless of outcome. |
| `conditional` | Evaluate `condition_expr` against shared memory keys. |
| `llm_decide` | Let the LLM decide at runtime. |
## Template variables
Use `{{variable_name}}` in `system_prompt` and `identity_prompt`.
Variables are defined in the top-level `variables:` map.
```yaml
variables:
spreadsheet_id: "1ZVxWDL..."
api_endpoint: "https://api.example.com"
nodes:
- id: start
system_prompt: |
Connect to spreadsheet: {{spreadsheet_id}}
API endpoint: {{api_endpoint}}
```
## Entry points
Default is a single manual entry point. For timer/scheduled triggers:
```yaml
entry_points:
- id: default
trigger_type: manual
- id: daily-check
trigger_type: timer
trigger_config:
interval_minutes: 30
```
## mcp_servers.json -- Still Supported
The `mcp_servers.json` file is still loaded automatically if present alongside
`agent.yaml`. You can also inline servers in the YAML:
```yaml
mcp_servers:
- name: hive_tools
- name: gcu-tools
```
Both approaches work. The JSON file takes precedence for backward compatibility.
## Migration from Python agents
Run the migration tool to convert existing agents:
```bash
uv run python -m framework.tools.migrate_agent exports/my_agent
```
This generates `agent.yaml` from the existing `agent.py` + `nodes/` + `config.py`.
The original files are left untouched. Once verified, you can delete the Python files.
## Files after migration
```
my_agent/
agent.yaml # The only required file
mcp_servers.json # Optional (can inline in YAML)
flowchart.json # Optional (auto-generated)
```
@@ -1,305 +1,193 @@
# Hive Agent Framework Condensed Reference
# Hive Agent Framework -- Condensed Reference
## Architecture
Agents are Python packages in `exports/`:
Agents are declarative JSON configs in `exports/`:
```
exports/my_agent/
├── __init__.py # MUST re-export ALL module-level vars from agent.py
├── __main__.py # CLI (run, tui, info, validate, shell)
├── agent.py # Graph construction (goal, edges, agent class)
├── config.py # Runtime config
├── nodes/__init__.py # Node definitions (NodeSpec)
├── mcp_servers.json # MCP tool server config
└── tests/ # pytest tests
agent.json # The entire agent definition
mcp_servers.json # MCP tool server config (optional, prefer registry refs)
```
## Agent Loading Contract
No Python files. No `__init__.py`, `__main__.py`, `config.py`, or `nodes/`.
`AgentRunner.load()` imports the package (`__init__.py`) and reads these
module-level variables via `getattr()`:
## Agent Loading
| Variable | Required | Default if missing | Consequence |
|----------|----------|--------------------|-------------|
| `goal` | YES | `None` | **FATAL** — "must define goal, nodes, edges" |
| `nodes` | YES | `None` | **FATAL** — same error |
| `edges` | YES | `None` | **FATAL** — same error |
| `entry_node` | no | `nodes[0].id` | Probably wrong node |
| `entry_points` | no | `{}` | **Nodes unreachable** — validation fails |
| `terminal_nodes` | **YES** | `[]` | **FATAL** — graph must have at least one terminal node |
| `pause_nodes` | no | `[]` | OK |
| `conversation_mode` | no | not passed | Isolated mode (no context carryover) |
| `identity_prompt` | no | not passed | No agent-level identity |
| `loop_config` | no | `{}` | No iteration limits |
| `triggers.json` (file) | no | not present | No triggers (timers, webhooks) |
`AgentLoader.load()` reads `agent.json` and builds the execution graph.
If `agent.py` exists (legacy), it's loaded as a Python module instead.
**CRITICAL:** `__init__.py` MUST import and re-export ALL of these from
`agent.py`. Missing exports silently fall back to defaults, causing
hard-to-debug failures.
## agent.json Schema
**Why `default_agent.validate()` is NOT sufficient:**
`validate()` checks the agent CLASS's internal graph (self.nodes, self.edges).
These are always correct because the constructor references agent.py's module
vars directly. But `AgentRunner.load()` reads from the PACKAGE (`__init__.py`),
not the class. So `validate()` passes while `AgentRunner.load()` fails.
Always test with `AgentRunner.load("exports/{name}")` — this is the same
code path the TUI and `hive run` use.
## Goal
Defines success criteria and constraints:
```python
goal = Goal(
id="kebab-case-id",
name="Display Name",
description="What the agent does",
success_criteria=[
SuccessCriterion(id="sc-id", description="...", metric="...", target="...", weight=0.25),
],
constraints=[
Constraint(id="c-id", description="...", constraint_type="hard", category="quality"),
],
)
```json
{
"name": "my-agent",
"version": "1.0.0",
"description": "What this agent does",
"goal": {
"description": "What to achieve",
"success_criteria": ["criterion 1", "criterion 2"],
"constraints": ["constraint 1"]
},
"identity_prompt": "You are a helpful agent.",
"conversation_mode": "continuous",
"loop_config": {
"max_iterations": 100,
"max_tool_calls_per_turn": 30,
"max_context_tokens": 32000
},
"mcp_servers": [
{"name": "hive_tools"},
{"name": "gcu-tools"}
],
"variables": {
"spreadsheet_id": "1ZVx..."
},
"nodes": [...],
"edges": [...],
"entry_node": "process",
"terminal_nodes": []
}
```
- 3-5 success criteria, weights sum to 1.0
- 1-5 constraints (hard/soft, categories: quality, accuracy, interaction, functional)
## NodeSpec Fields
## Template Variables
Use `{{variable_name}}` in `system_prompt` and `identity_prompt`. Variables
are defined in the top-level `variables` object:
```json
{
"variables": {"sheet_id": "1ZVx..."},
"nodes": [{
"id": "start",
"system_prompt": "Use sheet: {{sheet_id}}"
}]
}
```
## Node Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| id | str | required | kebab-case identifier |
| name | str | required | Display name |
| name | str | id | Display name |
| description | str | required | What the node does |
| node_type | str | required | `"event_loop"` or `"gcu"` (browser automation — see GCU Guide appendix) |
| input_keys | list[str] | required | Memory keys this node reads |
| output_keys | list[str] | required | Memory keys this node writes via set_output |
| node_type | str | "event_loop" | `"event_loop"` |
| input_keys | list | [] | Memory keys this node reads |
| output_keys | list | [] | Memory keys this node writes via set_output |
| system_prompt | str | "" | LLM instructions |
| tools | list[str] | [] | Tool names from MCP servers |
| client_facing | bool | False | If True, streams to user and blocks for input |
| nullable_output_keys | list[str] | [] | Keys that may remain unset |
| max_node_visits | int | 0 | 0=unlimited (default); >1 for one-shot feedback loops |
| max_retries | int | 3 | Retries on failure |
| tools | object | {} | Tool access policy (see below) |
| nullable_output_keys | list | [] | Keys that may remain unset |
| max_node_visits | int | 1 | 0=unlimited (for forever-alive agents) |
| success_criteria | str | "" | Natural language for judge evaluation |
| client_facing | bool | false | Whether output is shown to user |
## EdgeSpec Fields
## Tool Access Policies
Each node declares its tools via a policy object:
```json
{"tools": {"policy": "explicit", "allowed": ["web_search", "save_data"]}}
{"tools": {"policy": "all"}}
{"tools": {"policy": "none"}}
```
- `explicit` (default): only named tools. Empty `allowed` = zero tools.
- `all`: all tools from registry (e.g. for browser automation nodes).
- `none`: no tools (for handoff/summary nodes).
## Edge Fields
| Field | Type | Description |
|-------|------|-------------|
| id | str | kebab-case identifier |
| source | str | Source node ID |
| target | str | Target node ID |
| condition | EdgeCondition | ON_SUCCESS, ON_FAILURE, ALWAYS, CONDITIONAL |
| condition_expr | str | Python expression evaluated against memory (for CONDITIONAL) |
| priority | int | Positive=forward (evaluated first), negative=feedback (loop-back) |
| from_node | str | Source node ID |
| to_node | str | Target node ID |
| condition | str | `on_success`, `on_failure`, `always`, `conditional` |
| condition_expr | str | Python expression for conditional routing |
| priority | int | Higher = evaluated first |
condition_expr examples:
- `"needs_more_research == True"`
- `"str(next_action).lower() == 'revise'"`
## Key Patterns
### STEP 1/STEP 2 (Client-Facing Nodes)
```
**STEP 1 — Respond to the user (text only, NO tool calls):**
[Present information, ask questions]
**STEP 2 — After the user responds, call set_output:**
- set_output("key", "value based on user response")
```
This prevents premature set_output before user interaction.
### Fewer, Richer Nodes (CRITICAL)
**Hard limit: 3-6 nodes for most agents.** Never exceed 6 unless the user
explicitly requests a complex multi-phase pipeline.
**Hard limit: 3-6 nodes for most agents.** Each node boundary serializes
outputs and destroys in-context information. Merge unless:
1. Client-facing boundary (different interaction models)
2. Disjoint tool sets
3. Parallel execution (fan-out branches)
Each node boundary serializes outputs to shared memory and **destroys** all
in-context information: tool call results, intermediate reasoning, conversation
history. A research node that searches, fetches, and analyzes in ONE node keeps
all source material in its conversation context. Split across 3 nodes, each
downstream node only sees the serialized summary string.
**Decision framework — merge unless ANY of these apply:**
1. **Client-facing boundary** — Autonomous and client-facing work MUST be
separate nodes (different interaction models)
2. **Disjoint tool sets** — If tools are fundamentally different (e.g., web
search vs database), separate nodes make sense
3. **Parallel execution** — Fan-out branches must be separate nodes
**Red flags that you have too many nodes:**
- A node with 0 tools (pure LLM reasoning) → merge into predecessor/successor
- A node that sets only 1 trivial output → collapse into predecessor
- Multiple consecutive autonomous nodes → combine into one rich node
- A "report" node that presents analysis → merge into the client-facing node
- A "confirm" or "schedule" node that doesn't call any external service → remove
**Typical agent structure (2 nodes):**
**Typical structure (2 nodes):**
```
process (autonomous) ←→ review (client-facing)
```
The queen owns intake — she gathers requirements from the user, then
passes structured input via `run_agent_with_input(task)`. When building
the agent, design the entry node's `input_keys` to match what the queen
will provide at run time. Worker agents should NOT have a client-facing
intake node. Client-facing nodes are for mid-execution review/approval only.
For simpler agents, just 1 autonomous node:
```
process (autonomous) — loops back to itself
process (autonomous) <-> review (queen-mediated)
```
### nullable_output_keys
For inputs that only arrive on certain edges:
```python
research_node = NodeSpec(
input_keys=["brief", "feedback"],
nullable_output_keys=["feedback"], # Only present on feedback edge
max_node_visits=3,
)
```
### Mutually Exclusive Outputs
For routing decisions:
```python
review_node = NodeSpec(
output_keys=["approved", "feedback"],
nullable_output_keys=["approved", "feedback"], # Node sets one or the other
)
```
### Continuous Loop Pattern
Mark the primary event_loop node as terminal: `terminal_nodes=["process"]`.
The node has `output_keys` and can complete when the agent finishes its work.
Use `conversation_mode="continuous"` to preserve context across transitions.
The queen owns intake. Worker agents should NOT have a client-facing intake
node. Mid-execution review should happen through queen escalation.
### set_output
- Synthetic tool injected by framework
- Call separately from real tool calls (separate turn)
- `set_output("key", "value")` stores to shared memory
- `set_output("key", "value")` stores to the shared buffer
## Edge Conditions
| Condition | When |
|-----------|------|
| ON_SUCCESS | Node completed successfully |
| ON_FAILURE | Node failed |
| ALWAYS | Unconditional |
| CONDITIONAL | condition_expr evaluates to True against memory |
condition_expr examples:
- `"needs_more_research == True"`
- `"str(next_action).lower() == 'new_agent'"`
- `"feedback is not None"`
## Graph Lifecycle
### Graph Lifecycle
| Pattern | terminal_nodes | When |
|---------|---------------|------|
| **Continuous loop** | `["node-with-output-keys"]` | **DEFAULT for all agents** |
| Continuous loop | `["node-with-output-keys"]` | DEFAULT for all agents |
| Linear | `["last-node"]` | One-shot/batch agents |
**Every graph must have at least one terminal node.** Terminal nodes
define where execution ends. For interactive agents that loop continuously,
mark the primary event_loop node as terminal (it has `output_keys` and can
complete at any point). The framework default for `max_node_visits` is 0
(unbounded), so nodes work correctly in continuous loops without explicit
override. Only set `max_node_visits > 0` in one-shot agents with feedback loops.
Every node must have at least one outgoing edge — no dead ends.
Every graph must have at least one terminal node.
## Continuous Conversation Mode
### Continuous Conversation Mode
`conversation_mode` has ONLY two valid states:
- `"continuous"` recommended for interactive agents
- Omit entirely isolated per-node conversations (each node starts fresh)
- `"continuous"` -- recommended (context carries across node transitions)
- Omit entirely -- isolated per-node conversations
**INVALID values** (do NOT use): `"client_facing"`, `"interactive"`,
`"adaptive"`, `"shared"`. These do not exist in the framework.
When `conversation_mode="continuous"`:
- Same conversation thread carries across node transitions
- Layered system prompts: identity (agent-level) + narrative + focus (per-node)
- Transition markers inserted at boundaries
- Compaction happens opportunistically at phase transitions
**INVALID values:** `"client_facing"`, `"interactive"`, `"shared"`.
## loop_config
Only three valid keys:
```python
loop_config = {
"max_iterations": 100, # Max LLM turns per node visit
"max_tool_calls_per_turn": 20, # Max tool calls per LLM response
"max_context_tokens": 32000, # Triggers conversation compaction
```json
{
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_context_tokens": 32000
}
```
**INVALID keys** (do NOT use): `"strategy"`, `"mode"`, `"timeout"`,
`"temperature"`. These are silently ignored or cause errors.
## Data Tools (Spillover)
For large data that exceeds context:
- `save_data(filename, data)` — Write to session data dir
- `load_data(filename, offset, limit)` — Read with pagination
- `list_data_files()` — List files
- `serve_file_to_user(filename, label)` — Clickable file:// URI
- `save_data(filename, data)` -- write to session data dir
- `load_data(filename, offset, limit)` -- read with pagination
- `list_data_files()` -- list files
- `serve_file_to_user(filename, label)` -- clickable file URI
`data_dir` is auto-injected by framework — LLM never sees it.
`data_dir` is auto-injected by framework.
## Fan-Out / Fan-In
Multiple ON_SUCCESS edges from same source parallel execution via asyncio.gather().
- Parallel nodes must have disjoint output_keys
- Only one branch may have client_facing nodes
- Fan-in node gets all outputs in shared memory
Multiple `on_success` edges from same source = parallel execution.
Parallel nodes must have disjoint output_keys.
## Judge System
- **Implicit** (default): ACCEPTs when LLM finishes with no tool calls and all required outputs set
- **SchemaJudge**: Validates against Pydantic model
- **Custom**: Implement `evaluate(context) -> JudgeVerdict`
Judge is the SOLE acceptance mechanism — no ad-hoc framework gating.
## Triggers (Timers, Webhooks)
For agents that react to external events, create a `triggers.json` file
in the agent's export directory:
```json
[
{
"id": "daily-check",
"name": "Daily Check",
"trigger_type": "timer",
"trigger_config": {"cron": "0 9 * * *"},
"task": "Run the daily check process"
}
]
```
### Key Fields
- `trigger_type`: `"timer"` or `"webhook"`
- `trigger_config`: `{"cron": "0 9 * * *"}` or `{"interval_minutes": 20}`
- `task`: describes what the worker should do when the trigger fires
- Triggers can also be created/removed at runtime via `set_trigger` / `remove_trigger` queen tools
## Tool Discovery
Do NOT rely on a static tool list — it will be outdated. Always call
`list_agent_tools()` with NO arguments first to see ALL available tools.
Only use `group=` or `output_schema=` as follow-up calls after seeing the
full list.
Always call `list_agent_tools()` first to see available tools.
Do NOT rely on a static tool list.
```
list_agent_tools() # ALWAYS call this first
list_agent_tools(group="gmail", output_schema="full") # then drill into a category
list_agent_tools("exports/my_agent/mcp_servers.json") # specific agent's tools
list_agent_tools() # full summary
list_agent_tools(group="gmail", output_schema="full") # drill into category
```
After building, run `validate_agent_package("{name}")` to check everything at once.
Common tool categories (verify via list_agent_tools):
- **Web**: search, scrape, PDF
- **Data**: save/load/append/list data files, serve to user
- **File**: view, write, replace, diff, list, grep
- **Communication**: email, gmail, slack, telegram
- **CRM**: hubspot, apollo, calcom
- **GitHub**: stargazers, user profiles, repos
- **Vision**: image analysis
- **Time**: current time
After building, run `validate_agent_package("{name}")` to check everything.
@@ -1,158 +1,80 @@
# GCU Browser Automation Guide
# Browser Automation Guide
## When to Use GCU Nodes
## When to Use Browser Nodes
Use `node_type="gcu"` when:
- The user's workflow requires **navigating real websites** (scraping, form-filling, social media interaction, testing web UIs)
- The task involves **dynamic/JS-rendered pages** that `web_scrape` cannot handle (SPAs, infinite scroll, login-gated content)
- The agent needs to **interact with a website** — clicking, typing, scrolling, selecting, uploading files
Use browser nodes (with `tools: {policy: "all"}`) when:
- The task requires interacting with web pages (clicking, typing, navigating)
- No API is available for the target service
- The user is already logged in to the target site
Do NOT use GCU for:
- Static content that `web_scrape` handles fine
- API-accessible data (use the API directly)
- PDF/file processing
- Anything that doesn't require a browser UI
## What Browser Nodes Are
## What GCU Nodes Are
- Regular `event_loop` nodes with browser tools from gcu-tools MCP server
- Set `tools: {policy: "all"}` to give access to all browser tools
- Wire into the graph with edges like any other node
- No special node_type needed
- `node_type="gcu"` — a declarative enhancement over `event_loop`
- Framework auto-prepends browser best-practices system prompt
- Framework auto-includes all 31 browser tools from `gcu-tools` MCP server
- Same underlying `EventLoopNode` class — no new imports needed
- `tools=[]` is correct — tools are auto-populated at runtime
## Available Browser Tools
## GCU Architecture Pattern
All tools are prefixed with `browser_`:
- `browser_start`, `browser_open`, `browser_navigate` — launch/navigate
- `browser_click`, `browser_click_coordinate`, `browser_fill`, `browser_type`, `browser_type_focused` — interact
- `browser_press` (with optional `modifiers=["ctrl"]` etc.) — keyboard shortcuts
- `browser_snapshot` — compact accessibility-tree read (structured)
<!-- vision-only -->
- `browser_screenshot` — visual capture (annotated PNG)
<!-- /vision-only -->
- `browser_shadow_query`, `browser_get_rect` — locate elements (shadow-piercing via `>>>`)
- `browser_scroll`, `browser_wait` — navigation helpers
- `browser_evaluate` — run JavaScript
- `browser_close`, `browser_close_finished` — tab cleanup
GCU nodes are **subagents** — invoked via `delegate_to_sub_agent()`, not connected via edges.
## Pick the right reading tool
- Primary nodes (`event_loop`, client-facing) orchestrate; GCU nodes do browser work
- Parent node declares `sub_agents=["gcu-node-id"]` and calls `delegate_to_sub_agent(agent_id="gcu-node-id", task="...")`
- GCU nodes set `max_node_visits=1` (single execution per delegation), `client_facing=False`
- GCU nodes use `output_keys=["result"]` and return structured JSON via `set_output("result", ...)`
**`browser_snapshot`** — compact accessibility tree of interactive elements. Fast, cheap, good for static or form-heavy pages where the DOM matches what's visually rendered (documentation, simple dashboards, search results, settings pages).
## GCU Node Definition Template
**`browser_screenshot`** — visual capture + metadata (`cssWidth`, `devicePixelRatio`, scale fields). Use this when `browser_snapshot` does not show the thing you need, when refs look stale, or when visual position/layout matters. This often happens on complex SPAs — LinkedIn, Twitter/X, Reddit, Gmail, Notion, Slack, Discord — and on sites using shadow DOM, virtual scrolling, React reconciliation, or dynamic layout.
```python
gcu_browser_node = NodeSpec(
id="gcu-browser-worker",
name="Browser Worker",
description="Browser subagent that does X.",
node_type="gcu",
client_facing=False,
max_node_visits=1,
input_keys=[],
output_keys=["result"],
tools=[], # Auto-populated with all browser tools
system_prompt="""\
You are a browser agent. Your job: [specific task].
Neither tool is "preferred" universally — they're for different jobs. Start with snapshot for page structure and ordinary controls; use screenshot as the fallback when snapshot can't find or verify the visible target. Activate the `browser-automation` skill for the full decision tree.
## Workflow
1. browser_start (only if no browser is running yet)
2. browser_open(url=TARGET_URL) — note the returned targetId
3. browser_snapshot to read the page
4. [task-specific steps]
5. set_output("result", JSON)
## Coordinate rule
## Output format
set_output("result", JSON) with:
- [field]: [type and description]
""",
)
Every browser tool that takes or returns coordinates operates in **fractions of the viewport (0..1 for both axes)**. Read a target's proportional position off `browser_screenshot` ("~35% from the left, ~20% from the top" → `(0.35, 0.20)`) and pass that to `browser_click_coordinate` / `browser_hover_coordinate` / `browser_press_at`. `browser_get_rect` and `browser_shadow_query` return `rect.cx` / `rect.cy` as fractions. The tools multiply by `cssWidth` / `cssHeight` internally — no scale awareness required. Fractions are used because every vision model (Claude, GPT-4o, Gemini, local VLMs) resizes/tiles images differently; proportions are invariant. Avoid raw `getBoundingClientRect()` via `browser_evaluate` for coord lookup; use `browser_get_rect` instead.
## System prompt tips for browser nodes
```
1. Start with browser_snapshot or the snapshot returned by the latest interaction.
2. If the target is missing, ambiguous, stale, or visibly present but absent from the tree,
use browser_screenshot to orient and then click by fractional coordinates.
3. Before typing into a rich-text editor (X compose, LinkedIn DM, Gmail, Reddit),
click the input area first with browser_click_coordinate so React / Draft.js /
Lexical register a native focus event, then use browser_type_focused(text=...)
for shadow-DOM inputs or browser_type(selector, text) for light-DOM inputs.
4. Use browser_wait(seconds=2-3) after navigation for SPA hydration.
5. If you hit an auth wall, call set_output with an error and move on.
6. Keep tool calls per turn <= 10 for reliability.
```
## Parent Node Template (orchestrating GCU subagents)
```python
orchestrator_node = NodeSpec(
id="orchestrator",
...
node_type="event_loop",
sub_agents=["gcu-browser-worker"],
system_prompt="""\
...
delegate_to_sub_agent(
agent_id="gcu-browser-worker",
task="Navigate to [URL]. Do [specific task]. Return JSON with [fields]."
)
...
""",
tools=[], # Orchestrator doesn't need browser tools
)
```
## mcp_servers.json with GCU
## Example
```json
{
"hive-tools": { ... },
"gcu-tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
"cwd": "../../tools",
"description": "GCU tools for browser automation"
}
"id": "scan-profiles",
"name": "Scan LinkedIn Profiles",
"description": "Navigate LinkedIn search results and collect profile data",
"tools": {"policy": "all"},
"input_keys": ["search_url"],
"output_keys": ["profiles"],
"system_prompt": "Navigate to the search URL via browser_navigate(wait_until='load', timeout_ms=20000). Wait 3s for SPA hydration. Use the returned snapshot to look for result cards first. If the cards are missing, stale, or visually present but absent from the tree, use browser_screenshot to orient; paginate through results by scrolling and use screenshots only when the snapshot cannot find or verify the visible cards..."
}
```
Note: `gcu-tools` is auto-added if any node uses `node_type="gcu"`, but including it explicitly is fine.
## GCU System Prompt Best Practices
Key rules to bake into GCU node prompts:
- Prefer `browser_snapshot` over `browser_get_text("body")` — compact accessibility tree vs 100KB+ raw HTML
- Always `browser_wait` after navigation
- Use large scroll amounts (~2000-5000) for lazy-loaded content
- For spillover files, use `run_command` with grep, not `read_file`
- If auth wall detected, report immediately — don't attempt login
- Keep tool calls per turn ≤10
- Tab isolation: when browser is already running, use `browser_open(background=true)` and pass `target_id` to every call
## Multiple Concurrent GCU Subagents
When a task can be parallelized across multiple sites or profiles, declare a distinct GCU
node for each and invoke them all in the same LLM turn. The framework batches all
`delegate_to_sub_agent` calls made in one turn and runs them with `asyncio.gather`, so
they execute concurrently — not sequentially.
**Each GCU subagent automatically gets its own isolated browser context** — no `profile=`
argument is needed in tool calls. The framework derives a unique profile from the subagent's
node ID and instance counter and injects it via an asyncio `ContextVar` before the subagent
runs.
### Example: three sites in parallel
```python
# Three distinct GCU nodes
gcu_site_a = NodeSpec(id="gcu-site-a", node_type="gcu", ...)
gcu_site_b = NodeSpec(id="gcu-site-b", node_type="gcu", ...)
gcu_site_c = NodeSpec(id="gcu-site-c", node_type="gcu", ...)
orchestrator = NodeSpec(
id="orchestrator",
node_type="event_loop",
sub_agents=["gcu-site-a", "gcu-site-b", "gcu-site-c"],
system_prompt="""\
Call all three subagents in a single response to run them in parallel:
delegate_to_sub_agent(agent_id="gcu-site-a", task="Scrape prices from site A")
delegate_to_sub_agent(agent_id="gcu-site-b", task="Scrape prices from site B")
delegate_to_sub_agent(agent_id="gcu-site-c", task="Scrape prices from site C")
""",
)
Connected via regular edges:
```
search-setup -> scan-profiles -> process-results
```
**Rules:**
- Use distinct node IDs for each concurrent task — sharing an ID shares the browser context.
- The GCU node prompts do not need to mention `profile=`; isolation is automatic.
- Cleanup is automatic at session end, but GCU nodes can call `browser_stop()` explicitly
if they want to release resources mid-run.
## Further detail
## GCU Anti-Patterns
- Using `browser_screenshot` to read text (use `browser_snapshot` instead; screenshots are for visual context only)
- Re-navigating after scrolling (resets scroll position)
- Attempting login on auth walls
- Forgetting `target_id` in multi-tab scenarios
- Putting browser tools directly on `event_loop` nodes instead of using GCU subagent pattern
- Making GCU nodes `client_facing=True` (they should be autonomous subagents)
For rich-text editor quirks (Lexical, Draft.js, ProseMirror), shadow-DOM shortcuts, `beforeunload` dialog neutralization, Trusted Types CSP on LinkedIn, keyboard shortcut dispatch, and per-site selector tables — **activate the `browser-automation` skill**. That skill has the full verified guidance and is refreshed against real production sites.
@@ -1,63 +0,0 @@
# Queen Memory — File System Structure
```
~/.hive/
├── queen/
│ ├── MEMORY.md ← Semantic memory
│ ├── memories/
│ │ ├── MEMORY-2026-03-09.md ← Episodic memory (today)
│ │ ├── MEMORY-2026-03-08.md
│ │ └── ...
│ └── session/
│ └── {session_id}/ ← One dir per session (or resumed-from session)
│ ├── conversations/
│ │ ├── parts/
│ │ │ ├── 00001.json ← One file per message (role, content, tool_calls)
│ │ │ ├── 00002.json
│ │ │ └── ...
│ │ └── spillover/
│ │ ├── conversation_1.md ← Compacted old conversation segments
│ │ ├── conversation_2.md
│ │ └── ...
│ └── data/
│ ├── adapt.md ← Working memory (session-scoped)
│ ├── web_search_1.txt ← Spillover: large tool results
│ ├── web_search_2.txt
│ └── ...
```
---
## The three memory tiers
| File | Tier | Written by | Read at |
|---|---|---|---|
| `MEMORY.md` | Semantic | Consolidation LLM (auto, post-session) | Session start (injected into system prompt) |
| `memories/MEMORY-YYYY-MM-DD.md` | Episodic | Queen via `write_to_diary` tool + consolidation LLM | Session start (today's file injected) |
| `data/adapt.md` | Working | Queen via `update_session_notes` tool | Every turn (inlined in system prompt) |
---
## Session directory naming
The session directory name is **`queen_resume_from`** when a cold-restore resumes an existing
session, otherwise the new **`session_id`**. This means resumed sessions accumulate all messages
in the original directory rather than fragmenting across multiple folders.
---
## Consolidation
`consolidate_queen_memory()` runs every **5 minutes** in the background and once more at session
end. It reads:
1. `conversations/parts/*.json` — full message history (user + assistant turns; tool results skipped)
2. `data/adapt.md` — current working notes
It then makes two LLM writes:
- Rewrites `MEMORY.md` in place (semantic memory — queen never touches this herself)
- Appends a timestamped prose entry to today's `memories/MEMORY-YYYY-MM-DD.md`
If the combined transcript exceeds ~200 K characters it is recursively binary-compacted via the
LLM before being sent to the consolidation model (mirrors `EventLoopNode._llm_compact`).
@@ -0,0 +1,984 @@
"""Reflection agent — background memory extraction for the queen.
A lightweight side agent that runs after each queen LLM turn. It inspects
recent conversation messages and extracts durable user knowledge into
individual memory files in the configured memory directories.
Two reflection types:
- **Short reflection**: after conversational queen turns. Distills
learnings into either global or queen-scoped memory.
- **Long reflection**: every 5 short reflections and on CONTEXT_COMPACTED.
Organises, deduplicates, and trims a memory directory.
Concurrency: an ``asyncio.Lock`` prevents overlapping runs. If a trigger
fires while a reflection is already active the event is skipped.
All reflections are fire-and-forget (spawned via ``asyncio.create_task``)
so they never block the queen's event loop.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import traceback
from datetime import datetime
from pathlib import Path
from typing import Any
from framework.agents.queen.queen_memory_v2 import (
GLOBAL_MEMORY_CATEGORIES,
MAX_FILE_SIZE_BYTES,
MAX_FILES,
format_memory_manifest,
global_memory_dir as _default_global_memory_dir,
parse_frontmatter,
scan_memory_files,
)
from framework.llm.provider import LLMResponse, Tool
from framework.tracker.llm_debug_logger import log_llm_turn
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Reflection tool definitions (internal — not in queen's main registry)
# ---------------------------------------------------------------------------
_REFLECTION_TOOLS: list[Tool] = [
Tool(
name="list_memory_files",
description=(
"List memory files with their type, name, and description. "
"When scope is omitted, returns all scopes grouped by scope."
),
parameters={
"type": "object",
"properties": {
"scope": {
"type": "string",
"description": "Optional scope to inspect: 'global' or 'queen'.",
},
},
"additionalProperties": False,
},
),
Tool(
name="read_memory_file",
description="Read the full content of a memory file by filename from a scope.",
parameters={
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "The filename (e.g. 'user-prefers-dark-mode.md').",
},
"scope": {
"type": "string",
"description": "Memory scope: 'global' or 'queen'. Defaults to 'global'.",
},
},
"required": ["filename"],
"additionalProperties": False,
},
),
Tool(
name="write_memory_file",
description=(
"Create or overwrite a memory file. Content should include YAML "
"frontmatter (name, description, type) followed by the memory body. "
f"Max file size: {MAX_FILE_SIZE_BYTES} bytes. Max files: {MAX_FILES}."
),
parameters={
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "Filename ending in .md (e.g. 'user-prefers-dark-mode.md').",
},
"scope": {
"type": "string",
"description": "Memory scope: 'global' or 'queen'. Defaults to 'global'.",
},
"content": {
"type": "string",
"description": "Full file content including frontmatter.",
},
},
"required": ["filename", "content"],
"additionalProperties": False,
},
),
Tool(
name="delete_memory_file",
description=(
"Delete a memory file by filename. Use during long reflection to prune stale or redundant memories."
),
parameters={
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "The filename to delete.",
},
"scope": {
"type": "string",
"description": "Memory scope: 'global' or 'queen'. Defaults to 'global'.",
},
},
"required": ["filename"],
"additionalProperties": False,
},
),
]
def _normalize_memory_dirs(
memory_dir: Path | dict[str, Path],
*,
queen_memory_dir: Path | None = None,
) -> dict[str, Path]:
"""Normalize memory directory input into a scope -> path mapping."""
if isinstance(memory_dir, dict):
return {scope: path for scope, path in memory_dir.items() if path is not None}
dirs: dict[str, Path] = {"global": memory_dir}
if queen_memory_dir is not None:
dirs["queen"] = queen_memory_dir
return dirs
def _scope_label(scope: str, queen_id: str | None = None) -> str:
"""Human-readable label for a memory scope."""
if scope == "queen":
return f"queen ({queen_id})" if queen_id else "queen"
return scope
def _resolve_memory_scope(args: dict[str, Any], memory_dirs: dict[str, Path]) -> str:
"""Resolve and validate the requested memory scope."""
raw_scope = args.get("scope")
if raw_scope is None:
if len(memory_dirs) == 1:
return next(iter(memory_dirs))
scope = "global"
else:
scope = str(raw_scope).strip().lower() or "global"
if scope not in memory_dirs:
available = ", ".join(sorted(memory_dirs))
raise ValueError(f"Invalid scope '{scope}'. Available scopes: {available}.")
return scope
def _format_multi_scope_manifest(
memory_dirs: dict[str, Path],
*,
queen_id: str | None = None,
) -> str:
"""Format a manifest that groups memory files by scope."""
blocks: list[str] = []
for scope, memory_dir in memory_dirs.items():
files = scan_memory_files(memory_dir)
label = _scope_label(scope, queen_id)
body = format_memory_manifest(files) if files else "(no memory files yet)"
blocks.append(f"## Scope: {label}\n\n{body}")
return "\n\n".join(blocks)
def _safe_memory_path(filename: str, memory_dir: Path) -> Path:
"""Resolve *filename* inside *memory_dir*, raising if it escapes."""
if not filename or filename.strip() != filename:
raise ValueError(f"Invalid filename: {filename!r}")
if "/" in filename or "\\" in filename or ".." in filename:
raise ValueError(f"Invalid filename: path components not allowed: {filename!r}")
candidate = (memory_dir / filename).resolve()
root = memory_dir.resolve()
if not candidate.is_relative_to(root):
raise ValueError(f"Path escapes memory directory: {filename!r}")
return candidate
def _execute_tool(
name: str,
args: dict[str, Any],
memory_dir: Path | dict[str, Path],
*,
queen_id: str | None = None,
) -> str:
"""Execute a reflection tool synchronously. Returns the result string."""
memory_dirs = _normalize_memory_dirs(memory_dir)
if name == "list_memory_files":
requested_scope = args.get("scope")
if requested_scope is not None:
try:
scope = _resolve_memory_scope(args, memory_dirs)
except ValueError as exc:
return f"ERROR: {exc}"
files = scan_memory_files(memory_dirs[scope])
logger.debug("reflect: tool list_memory_files[%s] → %d files", scope, len(files))
if not files:
return f"(no {scope} memory files yet)"
return format_memory_manifest(files)
return _format_multi_scope_manifest(memory_dirs, queen_id=queen_id)
if name == "read_memory_file":
filename = args.get("filename", "")
try:
scope = _resolve_memory_scope(args, memory_dirs)
except ValueError as exc:
return f"ERROR: {exc}"
try:
path = _safe_memory_path(filename, memory_dirs[scope])
except ValueError as exc:
return f"ERROR: {exc}"
if not path.exists() or not path.is_file():
return f"ERROR: File not found in {scope}: {filename}"
try:
return path.read_text(encoding="utf-8")
except OSError as e:
return f"ERROR: {e}"
if name == "write_memory_file":
filename = args.get("filename", "")
content = args.get("content", "")
try:
scope = _resolve_memory_scope(args, memory_dirs)
except ValueError as exc:
return f"ERROR: {exc}"
scope_dir = memory_dirs[scope]
if not filename.endswith(".md"):
return "ERROR: Filename must end with .md"
# Enforce global memory type restrictions.
fm = parse_frontmatter(content)
mem_type = (fm.get("type") or "").strip().lower()
if mem_type and mem_type not in GLOBAL_MEMORY_CATEGORIES:
return f"ERROR: Invalid memory type '{mem_type}'. Allowed types: {', '.join(GLOBAL_MEMORY_CATEGORIES)}."
# Enforce file size limit.
if len(content.encode("utf-8")) > MAX_FILE_SIZE_BYTES:
return f"ERROR: Content exceeds {MAX_FILE_SIZE_BYTES} byte limit."
# Enforce file cap (only for new files).
try:
path = _safe_memory_path(filename, scope_dir)
except ValueError as exc:
return f"ERROR: {exc}"
if not path.exists():
existing = list(scope_dir.glob("*.md"))
if len(existing) >= MAX_FILES:
return f"ERROR: File cap reached in {scope} ({MAX_FILES}). Delete a file first."
scope_dir.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
logger.debug(
"reflect: tool write_memory_file[%s] → %s (%d chars)",
scope,
filename,
len(content),
)
return f"Wrote {scope}:{filename} ({len(content)} chars)."
if name == "delete_memory_file":
filename = args.get("filename", "")
try:
scope = _resolve_memory_scope(args, memory_dirs)
except ValueError as exc:
return f"ERROR: {exc}"
try:
path = _safe_memory_path(filename, memory_dirs[scope])
except ValueError as exc:
return f"ERROR: {exc}"
if not path.exists():
return f"ERROR: File not found in {scope}: {filename}"
path.unlink()
logger.debug("reflect: tool delete_memory_file[%s] → %s", scope, filename)
return f"Deleted {scope}:{filename}."
return f"ERROR: Unknown tool: {name}"
# ---------------------------------------------------------------------------
# Reflection logging helper
# ---------------------------------------------------------------------------
def _log_reflection_turn(
*,
reflection_id: str,
iteration: int,
system_prompt: str,
messages: list[dict[str, Any]],
assistant_text: str,
tool_calls: list[dict[str, Any]],
tool_results: list[dict[str, Any]],
token_counts: dict[str, Any],
) -> None:
"""Log a reflection turn using the same JSONL format as the main agent loop."""
log_llm_turn(
node_id="reflection",
stream_id=reflection_id,
execution_id=reflection_id,
iteration=iteration,
system_prompt=system_prompt,
messages=messages,
assistant_text=assistant_text,
tool_calls=tool_calls,
tool_results=tool_results,
token_counts=token_counts,
)
# ---------------------------------------------------------------------------
# Mini event loop
# ---------------------------------------------------------------------------
_MAX_TURNS = 5
async def _reflection_loop(
llm: Any,
system: str,
user_msg: str,
memory_dir: Path | dict[str, Path],
max_turns: int = _MAX_TURNS,
*,
queen_id: str | None = None,
) -> tuple[bool, list[str], str]:
"""Run a mini tool-use loop: LLM → tool calls → repeat.
Returns (success, changed_files, last_text).
"""
messages: list[dict[str, Any]] = [{"role": "user", "content": user_msg}]
changed_files: list[str] = []
last_text: str = ""
reflection_id = f"reflection_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
token_counts: dict[str, Any] = {}
memory_dirs = _normalize_memory_dirs(memory_dir)
for _turn in range(max_turns):
logger.info("reflect: loop turn %d/%d (msgs=%d)", _turn + 1, max_turns, len(messages))
try:
resp: LLMResponse = await llm.acomplete(
messages=messages,
system=system,
tools=_REFLECTION_TOOLS,
max_tokens=2048,
)
except asyncio.CancelledError:
logger.warning("reflect: LLM call cancelled (task cancelled)")
return False, changed_files, last_text
except Exception:
logger.warning("reflect: LLM call failed", exc_info=True)
return False, changed_files, last_text
# Extract tool calls from litellm/OpenAI response object.
tool_calls_raw: list[dict[str, Any]] = []
raw = resp.raw_response
if raw is not None:
# litellm returns a ModelResponse object; tool calls live on
# choices[0].message.tool_calls as a list of ChatCompletionMessageToolCall.
try:
msg_obj = raw.choices[0].message
if hasattr(msg_obj, "tool_calls") and msg_obj.tool_calls:
for tc in msg_obj.tool_calls:
fn = tc.function
try:
args = json.loads(fn.arguments) if fn.arguments else {}
except (json.JSONDecodeError, TypeError):
args = {}
tool_calls_raw.append(
{
"id": tc.id,
"name": fn.name,
"input": args,
}
)
except (AttributeError, IndexError):
pass
logger.info(
"reflect: LLM responded, text=%d chars, tool_calls=%d",
len(resp.content or ""),
len(tool_calls_raw),
)
# Capture token counts from the LLM response.
try:
raw_usage = getattr(raw, "usage", None) if raw else None
if raw_usage:
token_counts = {
"model": getattr(raw, "model", ""),
"input": getattr(raw_usage, "prompt_tokens", 0) or 0,
"output": getattr(raw_usage, "completion_tokens", 0) or 0,
"cached": getattr(raw_usage, "prompt_tokens_details", None)
and getattr(raw_usage.prompt_tokens_details, "cached_tokens", 0),
"stop_reason": getattr(raw.choices[0], "finish_reason", "") if raw else "",
}
except Exception:
token_counts = {}
turn_text = resp.content or ""
if turn_text:
last_text = turn_text
assistant_msg: dict[str, Any] = {"role": "assistant", "content": turn_text}
if tool_calls_raw:
assistant_msg["tool_calls"] = [
{
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": json.dumps(tc.get("input", {})),
},
}
for tc in tool_calls_raw
]
messages.append(assistant_msg)
if not tool_calls_raw:
break
tool_results: list[dict[str, Any]] = []
for tc in tool_calls_raw:
tc_input = tc.get("input", {})
result = _execute_tool(tc["name"], tc_input, memory_dirs, queen_id=queen_id)
if tc["name"] in ("write_memory_file", "delete_memory_file"):
fname = tc_input.get("filename", "")
try:
scope = _resolve_memory_scope(tc_input, memory_dirs)
except ValueError:
scope = str(tc_input.get("scope", "global")).strip().lower() or "global"
if fname and not result.startswith("ERROR"):
changed_files.append(f"{scope}:{fname}")
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
tool_results.append({"tool_call_id": tc["id"], "name": tc["name"], "result": result})
# Log the reflection turn in the same JSONL format as the main agent loop.
_log_reflection_turn(
reflection_id=reflection_id,
iteration=_turn,
system_prompt=system,
messages=messages,
assistant_text=turn_text,
tool_calls=tool_calls_raw,
tool_results=tool_results,
token_counts=token_counts,
)
return True, changed_files, last_text
# ---------------------------------------------------------------------------
# System prompts
# ---------------------------------------------------------------------------
_CATEGORIES_STR = ", ".join(GLOBAL_MEMORY_CATEGORIES)
def _build_unified_short_reflect_system(queen_id: str | None = None) -> str:
"""Build the unified short reflection prompt across memory scopes."""
queen_scope = (
f"- `queen`: durable learnings specific to how queen '{queen_id}' should work with this user\n"
if queen_id
else ""
)
return f"""\
You are a reflection agent that distills durable knowledge about the USER
into persistent memory files. You run in the background after each
assistant turn.
Memory categories: {_CATEGORIES_STR}
Available memory scopes:
- `global`: durable user facts that should help every queen in future sessions
{queen_scope}
Expected format for each memory file:
```markdown
---
name: {{{{memory name}}}}
description: {{{{one-line description specific and search-friendly}}}}
type: {{{{{_CATEGORIES_STR}}}}}
---
{{{{memory content}}}}
```
Workflow (aim for 2 turns):
Turn 1 call list_memory_files without a scope to inspect all scopes, then
read_memory_file for any files that might need updating.
Turn 2 call write_memory_file / delete_memory_file with an explicit scope.
Rules:
- Make ONE coordinated storage decision per learning.
- Prefer `global` for broad user facts: identity, general preferences, environment,
and feedback that should help all queens.
- Prefer `queen` only for stable domain-specific learnings about how this queen
should reason, prioritize, communicate, or make tradeoffs for this user.
- Avoid storing the same fact in both scopes unless the scoped version adds
genuinely distinct queen-specific nuance. When in doubt, keep only one copy.
- Update existing files instead of creating duplicates when possible.
- If the same learning already exists in the wrong scope or both scopes,
you may update one file and delete the redundant one.
- Do NOT store task-specific details, code patterns, file paths, or ephemeral
session state.
- Keep files concise. Each file should cover ONE topic.
- If there is nothing worth remembering, do nothing (respond with a brief
reason no tool calls needed).
- File names should be kebab-case slugs ending in .md.
- For user identity/profile information about the human user (name, role,
background), ALWAYS use the canonical filename 'user-profile.md' in the
`global` scope. This is the single source of truth for user profile data,
shared with the settings UI.
- When updating `global:user-profile.md`, preserve the '## User Identity'
section it is managed by the settings UI. Never describe the assistant,
queen, or agent as the identity in this file. Add/update other sections
below it.
- Do NOT exceed {MAX_FILE_SIZE_BYTES} bytes per file or {MAX_FILES} total files per scope.
"""
def _build_unified_long_reflect_system(queen_id: str | None = None) -> str:
"""Build the unified housekeeping prompt across memory scopes."""
queen_scope = (
f"- `queen`: memories specific to how queen '{queen_id}' should work with this user\n" if queen_id else ""
)
return f"""\
You are a reflection agent performing a periodic housekeeping pass over the
memory system for this user.
Memory categories: {_CATEGORIES_STR}
Available memory scopes:
- `global`: facts useful to every queen
{queen_scope}
Workflow:
1. Call list_memory_files without a scope to inspect all scopes together.
2. Read files that look redundant, stale, overlapping, or misplaced.
3. Merge duplicates, move memories to the correct scope, and delete
redundant copies when appropriate.
4. Ensure descriptions are specific and search-friendly.
5. Enforce limits: max {MAX_FILES} files and {MAX_FILE_SIZE_BYTES} bytes per file in each scope.
Rules:
- Treat deduplication across scopes as part of the job, not just within a scope.
- Prefer `global` for broad durable user facts and `queen` for queen-specific nuance.
- If two files store materially the same fact, keep the best one and delete or
rewrite the redundant one.
- Prefer merging over deleting when the memories contain complementary signal.
- Remove memories that are stale, superseded, or misplaced.
- Keep the total collection lean and high-signal.
- Do NOT invent new information only reorganise what exists.
"""
# ---------------------------------------------------------------------------
# Short & long reflection entry points
# ---------------------------------------------------------------------------
async def _read_conversation_parts(session_dir: Path) -> list[dict[str, Any]]:
"""Read conversation parts from the queen session directory."""
from framework.storage.conversation_store import FileConversationStore
store = FileConversationStore(session_dir / "conversations")
return await store.read_parts()
async def run_short_reflection(
session_dir: Path,
llm: Any,
memory_dir: Path | None = None,
) -> None:
"""Run a global-only short reflection (compatibility wrapper)."""
logger.info("reflect: starting global short reflection for %s", session_dir)
mem_dir = memory_dir or _default_global_memory_dir()
await _run_short_reflection_with_prompt(
session_dir,
llm,
mem_dir,
system_prompt=_build_unified_short_reflect_system(),
log_label="global",
queen_id=None,
)
async def run_queen_short_reflection(
session_dir: Path,
llm: Any,
queen_id: str,
memory_dir: Path,
) -> None:
"""Run a queen-only short reflection (compatibility wrapper)."""
logger.info("reflect: starting queen short reflection for %s (%s)", session_dir, queen_id)
await _run_short_reflection_with_prompt(
session_dir,
llm,
{"queen": memory_dir},
system_prompt=_build_unified_short_reflect_system(queen_id),
log_label=f"queen:{queen_id}",
queen_id=queen_id,
)
async def run_unified_short_reflection(
session_dir: Path,
llm: Any,
*,
global_memory_dir: Path | None = None,
queen_memory_dir: Path | None = None,
queen_id: str | None = None,
) -> None:
"""Run one short reflection loop over all active memory scopes."""
global_dir = global_memory_dir or _default_global_memory_dir()
memory_dirs = {"global": global_dir}
if queen_memory_dir is not None and queen_id:
memory_dirs["queen"] = queen_memory_dir
logger.info(
"reflect: starting unified short reflection for %s (scopes=%s)",
session_dir,
sorted(memory_dirs),
)
await _run_short_reflection_with_prompt(
session_dir,
llm,
memory_dirs,
system_prompt=_build_unified_short_reflect_system(queen_id if "queen" in memory_dirs else None),
log_label="unified",
queen_id=queen_id if "queen" in memory_dirs else None,
)
async def _run_short_reflection_with_prompt(
session_dir: Path,
llm: Any,
memory_dir: Path | dict[str, Path],
*,
system_prompt: str,
log_label: str,
queen_id: str | None,
) -> None:
"""Run a short reflection with a scope-specific system prompt."""
mem_dir = memory_dir
messages = await _read_conversation_parts(session_dir)
if not messages:
logger.info("reflect: no conversation parts found in %s, skipping", session_dir)
return
transcript_lines: list[str] = []
for msg in messages[-50:]:
role = msg.get("role", "")
content = str(msg.get("content", "")).strip()
if role == "tool" or not content:
continue
label = "user" if role == "user" else "assistant"
if len(content) > 800:
content = content[:800] + ""
transcript_lines.append(f"[{label}]: {content}")
if not transcript_lines:
logger.info("reflect: no transcript lines after filtering, skipping")
return
transcript = "\n".join(transcript_lines)
user_msg = (
f"## Recent conversation ({len(messages)} messages total)\n\n"
f"{transcript}\n\n"
f"Timestamp: {datetime.now().isoformat(timespec='minutes')}"
)
_, changed, reason = await _reflection_loop(
llm,
system_prompt,
user_msg,
mem_dir,
queen_id=queen_id,
)
if changed:
logger.info("reflect: %s short reflection done, changed files: %s", log_label, changed)
else:
logger.info(
"reflect: %s short reflection done, no changes — %s",
log_label,
reason or "no reason",
)
async def run_long_reflection(
llm: Any,
memory_dir: Path | None = None,
*,
scope_label: str = "global",
) -> None:
"""Run a single-scope long reflection (compatibility wrapper)."""
logger.debug("reflect: starting long reflection for %s", scope_label)
mem_dir = memory_dir or _default_global_memory_dir()
files = scan_memory_files(mem_dir)
if not files:
logger.debug("reflect: no %s memory files, skipping long reflection", scope_label)
return
manifest = format_memory_manifest(files)
user_msg = (
f"## Current memory manifest ({len(files)} files)\n\n"
f"{manifest}\n\n"
f"Timestamp: {datetime.now().isoformat(timespec='minutes')}"
)
_, changed, reason = await _reflection_loop(
llm,
_build_unified_long_reflect_system(),
user_msg,
mem_dir,
queen_id=None,
)
if changed:
logger.debug(
"reflect: long reflection done for %s (%d files), changed: %s",
scope_label,
len(files),
changed,
)
else:
logger.debug(
"reflect: long reflection done for %s (%d files), no changes — %s",
scope_label,
len(files),
reason or "no reason",
)
async def run_unified_long_reflection(
llm: Any,
*,
global_memory_dir: Path | None = None,
queen_memory_dir: Path | None = None,
queen_id: str | None = None,
) -> None:
"""Run one housekeeping loop across all active memory scopes."""
global_dir = global_memory_dir or _default_global_memory_dir()
memory_dirs = {"global": global_dir}
if queen_memory_dir is not None and queen_id:
memory_dirs["queen"] = queen_memory_dir
manifest = _format_multi_scope_manifest(memory_dirs, queen_id=queen_id if "queen" in memory_dirs else None)
user_msg = (
"## Current memory manifest across scopes\n\n"
f"{manifest}\n\n"
f"Timestamp: {datetime.now().isoformat(timespec='minutes')}"
)
_, changed, reason = await _reflection_loop(
llm,
_build_unified_long_reflect_system(queen_id if "queen" in memory_dirs else None),
user_msg,
memory_dirs,
queen_id=queen_id if "queen" in memory_dirs else None,
)
if changed:
logger.debug("reflect: unified long reflection changed: %s", changed)
else:
logger.debug("reflect: unified long reflection no changes — %s", reason or "no reason")
async def run_shutdown_reflection(
session_dir: Path,
llm: Any,
memory_dir: Path | None = None,
*,
global_memory_dir_override: Path | None = None,
queen_memory_dir: Path | None = None,
queen_id: str | None = None,
) -> None:
"""Run a final short reflection on session shutdown.
Called during session teardown so recent conversation insights are
persisted before the session is destroyed.
"""
logger.info("reflect: running shutdown reflection for %s", session_dir)
try:
global_dir = global_memory_dir_override or memory_dir or _default_global_memory_dir()
await run_unified_short_reflection(
session_dir,
llm,
global_memory_dir=global_dir,
queen_memory_dir=queen_memory_dir,
queen_id=queen_id,
)
logger.info("reflect: shutdown reflection completed for %s", session_dir)
except asyncio.CancelledError:
logger.warning("reflect: shutdown reflection cancelled for %s", session_dir)
except Exception:
logger.warning("reflect: shutdown reflection failed", exc_info=True)
_write_error(
"shutdown reflection",
global_memory_dir_override or memory_dir or _default_global_memory_dir(),
)
# ---------------------------------------------------------------------------
# Event-bus integration
# ---------------------------------------------------------------------------
_LONG_REFLECT_INTERVAL = 5
_SHORT_REFLECT_TURN_INTERVAL = 3
_SHORT_REFLECT_COOLDOWN_SEC = 300.0
async def subscribe_reflection_triggers(
event_bus: Any,
session_dir: Path,
llm: Any,
global_memory_dir: Path | None = None,
queen_memory_dir: Path | None = None,
queen_id: str | None = None,
) -> list[str]:
"""Subscribe to queen turn events and return subscription IDs.
Call this once during queen setup. Returns a list of event-bus
subscription IDs for cleanup during session teardown.
"""
from framework.host.event_bus import EventType
global_mem_dir = global_memory_dir or _default_global_memory_dir()
queen_mem_dir = queen_memory_dir
_lock = asyncio.Lock()
_short_count = 0
_short_has_run = False
_last_short_time: float = 0.0
_background_tasks: set[asyncio.Task] = set()
async def _run_with_error_capture(coro: Any, *, context: str, memory_dir: Path) -> None:
try:
await coro
except Exception:
logger.warning("reflect: %s failed", context, exc_info=True)
_write_error(context, memory_dir)
async def _do_turn_reflect(is_interval: bool, count: int) -> None:
async with _lock:
await _run_with_error_capture(
run_unified_short_reflection(
session_dir,
llm,
global_memory_dir=global_mem_dir,
queen_memory_dir=queen_mem_dir,
queen_id=queen_id,
),
context="unified short reflection",
memory_dir=global_mem_dir,
)
if is_interval:
await _run_with_error_capture(
run_unified_long_reflection(
llm,
global_memory_dir=global_mem_dir,
queen_memory_dir=queen_mem_dir,
queen_id=queen_id,
),
context="unified long reflection",
memory_dir=global_mem_dir,
)
async def _do_compaction_reflect() -> None:
async with _lock:
await _run_with_error_capture(
run_unified_long_reflection(
llm,
global_memory_dir=global_mem_dir,
queen_memory_dir=queen_mem_dir,
queen_id=queen_id,
),
context="unified compaction reflection",
memory_dir=global_mem_dir,
)
def _fire_and_forget(coro: Any) -> None:
"""Spawn a background task and prevent GC before it finishes."""
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
async def _on_turn_complete(event: Any) -> None:
nonlocal _short_count, _short_has_run, _last_short_time
if getattr(event, "stream_id", None) != "queen":
return
_short_count += 1
event_data = getattr(event, "data", {}) or {}
stop_reason = event_data.get("stop_reason", "")
is_tool_turn = stop_reason in ("tool_use", "tool_calls")
is_interval = _short_count % _LONG_REFLECT_INTERVAL == 0
if is_tool_turn and not is_interval:
logger.debug("reflect: skipping tool turn (count=%d)", _short_count)
return
# Apply turn-interval and cooldown gates after the first reflection.
if _short_has_run:
now = time.monotonic()
turn_ok = _short_count % _SHORT_REFLECT_TURN_INTERVAL == 0
cooldown_ok = (now - _last_short_time) >= _SHORT_REFLECT_COOLDOWN_SEC
if not turn_ok and not cooldown_ok:
logger.debug(
"reflect: skipping, below turn/cooldown threshold (count=%d)",
_short_count,
)
return
if _lock.locked():
logger.debug("reflect: skipping, already running (count=%d)", _short_count)
return
_short_has_run = True
_last_short_time = time.monotonic()
logger.debug(
"reflect: triggered (count=%d, interval=%s, stop_reason=%s)",
_short_count,
is_interval,
stop_reason,
)
_fire_and_forget(_do_turn_reflect(is_interval, _short_count))
async def _on_compaction(event: Any) -> None:
if getattr(event, "stream_id", None) != "queen":
return
if _lock.locked():
logger.debug("reflect: skipping compaction trigger, already running")
return
logger.debug("reflect: compaction triggered long reflection")
_fire_and_forget(_do_compaction_reflect())
sub_ids: list[str] = []
sub1 = event_bus.subscribe(
event_types=[EventType.LLM_TURN_COMPLETE],
handler=_on_turn_complete,
)
sub_ids.append(sub1)
sub2 = event_bus.subscribe(
event_types=[EventType.CONTEXT_COMPACTED],
handler=_on_compaction,
)
sub_ids.append(sub2)
return sub_ids
def _write_error(context: str, memory_dir: Path) -> None:
"""Best-effort write of the last traceback to an error file."""
try:
error_path = memory_dir / ".reflection_error.txt"
error_path.parent.mkdir(parents=True, exist_ok=True)
error_path.write_text(
f"context: {context}\ntime: {datetime.now().isoformat()}\n\n{traceback.format_exc()}",
encoding="utf-8",
)
except OSError:
pass
@@ -22,10 +22,10 @@ def mock_mode():
@pytest_asyncio.fixture(scope="session")
async def runner(tmp_path_factory, mock_mode):
from framework.runner.runner import AgentRunner
from framework.loader.agent_loader import AgentLoader
storage = tmp_path_factory.mktemp("agent_storage")
r = AgentRunner.load(AGENT_PATH, mock_mode=mock_mode, storage_path=storage)
r = AgentLoader.load(AGENT_PATH, mock_mode=mock_mode, storage_path=storage)
r._setup()
yield r
await r.cleanup_async()
@@ -1,27 +0,0 @@
"""Queen's ticket receiver entry point.
When a WORKER_ESCALATION_TICKET event is emitted on the shared EventBus,
this entry point fires and routes to the ``ticket_triage`` node, where the
Queen deliberates and decides whether to notify the operator.
Isolation level is ``isolated`` the queen's triage memory is kept separate
from the worker's shared memory. Each ticket triage runs in its own context.
"""
from __future__ import annotations
from framework.graph.edge import AsyncEntryPointSpec
TICKET_RECEIVER_ENTRY_POINT = AsyncEntryPointSpec(
id="ticket_receiver",
name="Worker Escalation Ticket Receiver",
entry_node="ticket_triage",
trigger_type="event",
trigger_config={
"event_types": ["worker_escalation_ticket"],
# Do not fire on our own graph's events (prevents loops if queen
# somehow emits a worker_escalation_ticket for herself)
"exclude_own_graph": True,
},
isolation_level="isolated",
)
-286
View File
@@ -1,286 +0,0 @@
"""Worker per-run digest (run diary).
Storage layout:
~/.hive/agents/{agent_name}/runs/{run_id}/digest.md
Each completed or failed worker run gets one digest file. The queen reads
these via get_worker_status(focus='diary') before digging into live runtime
logs the diary is a cheap, persistent record that survives across sessions.
"""
from __future__ import annotations
import logging
import traceback
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from framework.runtime.event_bus import AgentEvent, EventBus
logger = logging.getLogger(__name__)
_DIGEST_SYSTEM = """\
You maintain run digests for a worker agent.
A run digest is a concise, factual record of a single task execution.
Write 3-6 sentences covering:
- What the worker was asked to do (the task/goal)
- What approach it took and what tools it used
- What the outcome was (success, partial, or failure and why if relevant)
- Any notable issues, retries, or escalations to the queen
Write in third person past tense. Be direct and specific.
Omit routine tool invocations unless the result matters.
Output only the digest prose no headings, no code fences.
"""
def _worker_runs_dir(agent_name: str) -> Path:
return Path.home() / ".hive" / "agents" / agent_name / "runs"
def digest_path(agent_name: str, run_id: str) -> Path:
return _worker_runs_dir(agent_name) / run_id / "digest.md"
def _collect_run_events(bus: EventBus, run_id: str, limit: int = 2000) -> list[AgentEvent]:
"""Collect all events belonging to *run_id* from the bus history.
Strategy: find the EXECUTION_STARTED event that carries ``run_id``,
extract its ``execution_id``, then query the bus by that execution_id.
This works because TOOL_CALL_*, EDGE_TRAVERSED, NODE_STALLED etc. carry
execution_id but not run_id.
Falls back to a full-scan run_id filter when EXECUTION_STARTED is not
found (e.g. bus was rotated).
"""
from framework.runtime.event_bus import EventType
# Pass 1: find execution_id via EXECUTION_STARTED with matching run_id
started = bus.get_history(event_type=EventType.EXECUTION_STARTED, limit=limit)
exec_id: str | None = None
for e in started:
if getattr(e, "run_id", None) == run_id and e.execution_id:
exec_id = e.execution_id
break
if exec_id:
return bus.get_history(execution_id=exec_id, limit=limit)
# Fallback: scan all events and match by run_id attribute
return [e for e in bus.get_history(limit=limit) if getattr(e, "run_id", None) == run_id]
def _build_run_context(
events: list[AgentEvent],
outcome_event: AgentEvent | None,
) -> str:
"""Assemble a plain-text run context string for the digest LLM call."""
from framework.runtime.event_bus import EventType
# Reverse so events are in chronological order
events_chron = list(reversed(events))
lines: list[str] = []
# Task input from EXECUTION_STARTED
started = [e for e in events_chron if e.type == EventType.EXECUTION_STARTED]
if started:
inp = started[0].data.get("input", {})
if inp:
lines.append(f"Task input: {str(inp)[:400]}")
# Duration (elapsed so far if no outcome yet)
ref_ts = outcome_event.timestamp if outcome_event else datetime.utcnow()
if started:
elapsed = (ref_ts - started[0].timestamp).total_seconds()
m, s = divmod(int(elapsed), 60)
lines.append(f"Duration so far: {m}m {s}s" if m else f"Duration so far: {s}s")
# Outcome
if outcome_event is None:
lines.append("Status: still running (mid-run snapshot)")
elif outcome_event.type == EventType.EXECUTION_COMPLETED:
out = outcome_event.data.get("output", {})
out_str = f"Outcome: completed. Output: {str(out)[:300]}"
lines.append(out_str if out else "Outcome: completed.")
else:
err = outcome_event.data.get("error", "")
lines.append(f"Outcome: failed. Error: {str(err)[:300]}" if err else "Outcome: failed.")
# Node path (edge traversals)
edges = [e for e in events_chron if e.type == EventType.EDGE_TRAVERSED]
if edges:
parts = [
f"{e.data.get('source_node', '?')}->{e.data.get('target_node', '?')}"
for e in edges[-20:]
]
lines.append(f"Node path: {', '.join(parts)}")
# Tools used
tool_events = [e for e in events_chron if e.type == EventType.TOOL_CALL_COMPLETED]
if tool_events:
names = [e.data.get("tool_name", "?") for e in tool_events]
counts = Counter(names)
summary = ", ".join(f"{name}×{n}" if n > 1 else name for name, n in counts.most_common())
lines.append(f"Tools used: {summary}")
# Note any tool errors
errors = [e for e in tool_events if e.data.get("is_error")]
if errors:
err_names = Counter(e.data.get("tool_name", "?") for e in errors)
lines.append(f"Tool errors: {dict(err_names)}")
# Issues
issue_map = {
EventType.NODE_STALLED: "stall",
EventType.NODE_TOOL_DOOM_LOOP: "doom loop",
EventType.CONSTRAINT_VIOLATION: "constraint violation",
EventType.NODE_RETRY: "retry",
}
issue_parts: list[str] = []
for evt_type, label in issue_map.items():
n = sum(1 for e in events_chron if e.type == evt_type)
if n:
issue_parts.append(f"{n} {label}(s)")
if issue_parts:
lines.append(f"Issues: {', '.join(issue_parts)}")
# Escalations to queen
escalations = [e for e in events_chron if e.type == EventType.ESCALATION_REQUESTED]
if escalations:
lines.append(f"Escalations to queen: {len(escalations)}")
# Final LLM output snippet (last LLM_TEXT_DELTA snapshot)
text_events = [e for e in reversed(events_chron) if e.type == EventType.LLM_TEXT_DELTA]
if text_events:
snapshot = text_events[0].data.get("snapshot", "") or ""
if snapshot:
lines.append(f"Final LLM output: {snapshot[-400:].strip()}")
return "\n".join(lines)
async def consolidate_worker_run(
agent_name: str,
run_id: str,
outcome_event: AgentEvent | None,
bus: EventBus,
llm: Any,
) -> None:
"""Write (or overwrite) the digest for a worker run.
Called fire-and-forget either:
- After EXECUTION_COMPLETED / EXECUTION_FAILED (outcome_event set, final write)
- Periodically during a run on a cooldown timer (outcome_event=None, mid-run snapshot)
The digest file is always overwritten so each call produces the freshest view.
The final completion/failure call supersedes any mid-run snapshot.
Args:
agent_name: Worker agent directory name (determines storage path).
run_id: The run ID.
outcome_event: EXECUTION_COMPLETED or EXECUTION_FAILED event, or None for
a mid-run snapshot.
bus: The session EventBus (shared queen + worker).
llm: LLMProvider with an acomplete() method.
"""
try:
events = _collect_run_events(bus, run_id)
run_context = _build_run_context(events, outcome_event)
if not run_context:
logger.debug("worker_memory: no events for run %s, skipping digest", run_id)
return
is_final = outcome_event is not None
logger.info(
"worker_memory: generating %s digest for run %s ...",
"final" if is_final else "mid-run",
run_id,
)
from framework.agents.queen.config import default_config
resp = await llm.acomplete(
messages=[{"role": "user", "content": run_context}],
system=_DIGEST_SYSTEM,
max_tokens=min(default_config.max_tokens, 512),
)
digest_text = (resp.content or "").strip()
if not digest_text:
logger.warning("worker_memory: LLM returned empty digest for run %s", run_id)
return
path = digest_path(agent_name, run_id)
path.parent.mkdir(parents=True, exist_ok=True)
from framework.runtime.event_bus import EventType
ts = (outcome_event.timestamp if outcome_event else datetime.utcnow()).strftime(
"%Y-%m-%d %H:%M"
)
if outcome_event is None:
status = "running"
elif outcome_event.type == EventType.EXECUTION_COMPLETED:
status = "completed"
else:
status = "failed"
path.write_text(
f"# {run_id}\n\n**{ts}** | {status}\n\n{digest_text}\n",
encoding="utf-8",
)
logger.info(
"worker_memory: %s digest written for run %s (%d chars)",
status,
run_id,
len(digest_text),
)
except Exception:
tb = traceback.format_exc()
logger.exception("worker_memory: digest failed for run %s", run_id)
# Persist the error so it's findable without log access
error_path = _worker_runs_dir(agent_name) / run_id / "digest_error.txt"
try:
error_path.parent.mkdir(parents=True, exist_ok=True)
error_path.write_text(
f"run_id: {run_id}\ntime: {datetime.now().isoformat()}\n\n{tb}",
encoding="utf-8",
)
except Exception:
pass
def read_recent_digests(agent_name: str, max_runs: int = 5) -> list[tuple[str, str]]:
"""Return recent run digests as [(run_id, content), ...], newest first.
Args:
agent_name: Worker agent directory name.
max_runs: Maximum number of digests to return.
Returns:
List of (run_id, digest_content) tuples, ordered newest first.
"""
runs_dir = _worker_runs_dir(agent_name)
if not runs_dir.exists():
return []
digest_files = sorted(
runs_dir.glob("*/digest.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)[:max_runs]
result: list[tuple[str, str]] = []
for f in digest_files:
try:
content = f.read_text(encoding="utf-8").strip()
if content:
result.append((f.parent.name, content))
except OSError:
continue
return result
+33 -53
View File
@@ -2,18 +2,22 @@
Command-line interface for Aden Hive.
Usage:
hive run exports/my-agent --input '{"key": "value"}'
hive info exports/my-agent
hive validate exports/my-agent
hive list exports/
hive dispatch exports/ --input '{"key": "value"}'
hive shell exports/my-agent
hive serve Start the HTTP API server
hive open Start the server and open the dashboard
hive queen list List queen profiles
hive queen show <queen_id> Inspect a queen profile
hive queen sessions <queen_id> List a queen's sessions
hive colony list List colonies on disk
hive colony info <name> Inspect a colony
hive colony delete <name> Delete a colony
hive session list List live sessions (use --cold for on-disk)
hive session stop <session_id> Stop a live session
hive chat <session_id> "msg" Send a message to a live queen
Testing commands:
hive test-run <agent_path> --goal <goal_id>
hive test-debug <agent_path> <test_name>
hive test-list <agent_path>
hive test-stats <agent_path>
Subsystems:
hive skill ... Manage skills (~/.hive/skills/)
hive mcp ... Manage MCP servers
hive debugger LLM debug log viewer
"""
import argparse
@@ -21,84 +25,60 @@ import sys
from pathlib import Path
def _configure_paths():
"""Auto-configure sys.path so agents in exports/ are discoverable.
def _configure_paths() -> None:
"""Auto-configure sys.path so the framework is importable from any cwd.
Resolves the project root by walking up from this file (framework/cli.py lives
inside core/framework/) or from CWD, then adds the exports/ directory to sys.path
if it exists. This eliminates the need for manual PYTHONPATH configuration.
Walks up from this file to find the project root, then ensures
`core/` is on sys.path so `framework.*` imports resolve when the
package isn't installed via `pip install -e .`.
"""
# Strategy 1: resolve relative to this file (works when installed via pip install -e core/)
framework_dir = Path(__file__).resolve().parent # core/framework/
core_dir = framework_dir.parent # core/
project_root = core_dir.parent # project root
# Strategy 2: if project_root doesn't look right, fall back to CWD
if not (project_root / "exports").is_dir() and not (project_root / "core").is_dir():
if not (project_root / "core").is_dir():
project_root = Path.cwd()
# Add exports/ to sys.path so agents are importable as top-level packages
exports_dir = project_root / "exports"
if exports_dir.is_dir():
exports_str = str(exports_dir)
if exports_str not in sys.path:
sys.path.insert(0, exports_str)
# Add examples/templates/ to sys.path so template agents are importable
templates_dir = project_root / "examples" / "templates"
if templates_dir.is_dir():
templates_str = str(templates_dir)
if templates_str not in sys.path:
sys.path.insert(0, templates_str)
# Ensure core/ is also in sys.path (for non-editable-install scenarios)
core_str = str(project_root / "core")
if (project_root / "core").is_dir() and core_str not in sys.path:
sys.path.insert(0, core_str)
# Add core/framework/agents/ so framework agents are importable as top-level packages
framework_agents_dir = project_root / "core" / "framework" / "agents"
if framework_agents_dir.is_dir():
fa_str = str(framework_agents_dir)
if fa_str not in sys.path:
sys.path.insert(0, fa_str)
def main():
def main() -> None:
_configure_paths()
parser = argparse.ArgumentParser(
prog="hive",
description="Aden Hive - Build and run goal-driven agents",
description="Aden Hive — Queens, colonies, and live agent sessions",
)
parser.add_argument(
"--model",
default="claude-haiku-4-5-20251001",
help="Anthropic model to use",
help="Default LLM model (Anthropic ID)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# Register runner commands (run, info, validate, list, dispatch, shell)
from framework.runner.cli import register_commands
# Core commands: serve, open, queen, colony, session, chat
from framework.loader.cli import register_commands
register_commands(subparsers)
# Register testing commands (test-run, test-debug, test-list, test-stats)
from framework.testing.cli import register_testing_commands
register_testing_commands(subparsers)
# Register skill commands (skill list, skill trust, ...)
# Skill management (~/.hive/skills/)
from framework.skills.cli import register_skill_commands
register_skill_commands(subparsers)
# Register debugger commands (debugger)
# LLM debug log viewer
from framework.debugger.cli import register_debugger_commands
register_debugger_commands(subparsers)
# MCP server registry
from framework.loader.mcp_registry_cli import register_mcp_commands
register_mcp_commands(subparsers)
args = parser.parse_args()
if hasattr(args, "func"):
+123 -17
View File
@@ -12,13 +12,47 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from framework.graph.edge import DEFAULT_MAX_TOKENS
DEFAULT_MAX_TOKENS = 8192
# ---------------------------------------------------------------------------
# Hive home directory structure
# ---------------------------------------------------------------------------
HIVE_HOME = Path.home() / ".hive"
QUEENS_DIR = HIVE_HOME / "agents" / "queens"
COLONIES_DIR = HIVE_HOME / "colonies"
MEMORIES_DIR = HIVE_HOME / "memories"
def queen_dir(queen_name: str = "default") -> Path:
"""Return the storage directory for a named queen agent."""
return QUEENS_DIR / queen_name
def colony_dir(colony_name: str) -> Path:
"""Return the directory for a named colony."""
return COLONIES_DIR / colony_name
def memory_dir(scope: str, name: str | None = None) -> Path:
"""Return memory dir for a scope.
Examples::
memory_dir("global") -> ~/.hive/memories/global
memory_dir("colonies", "my_agent") -> ~/.hive/memories/colonies/my_agent
memory_dir("agents/queens", "default")-> ~/.hive/memories/agents/queens/default
memory_dir("agents", "worker_name") -> ~/.hive/memories/agents/worker_name
"""
base = MEMORIES_DIR / scope
return base / name if name else base
# ---------------------------------------------------------------------------
# Low-level config file access
# ---------------------------------------------------------------------------
HIVE_CONFIG_FILE = Path.home() / ".hive" / "configuration.json"
HIVE_CONFIG_FILE = HIVE_HOME / "configuration.json"
# Hive LLM router endpoint (Anthropic-compatible).
# litellm's Anthropic handler appends /v1/messages, so this is just the base host.
@@ -42,6 +76,48 @@ def get_hive_config() -> dict[str, Any]:
return {}
# ---------------------------------------------------------------------------
# Credential store helpers (for BYOK keys)
# ---------------------------------------------------------------------------
# Provider name → credential store ID mapping
_PROVIDER_CRED_MAP: dict[str, str] = {
"anthropic": "anthropic",
"openai": "openai",
"gemini": "gemini",
"google": "gemini",
"minimax": "minimax",
"groq": "groq",
"cerebras": "cerebras",
"openrouter": "openrouter",
"mistral": "mistral",
"together": "together",
"together_ai": "together",
"deepseek": "deepseek",
"kimi": "kimi",
"hive": "hive",
}
def _get_api_key_from_credential_store(provider: str) -> str | None:
"""Look up a BYOK API key from the encrypted credential store.
Returns None if no key is found or the credential store is unavailable.
"""
if not os.environ.get("HIVE_CREDENTIAL_KEY"):
return None
cred_id = _PROVIDER_CRED_MAP.get(provider.lower())
if not cred_id:
return None
try:
from framework.credentials import CredentialStore
store = CredentialStore.with_encrypted_storage()
return store.get(cred_id)
except Exception:
return None
# ---------------------------------------------------------------------------
# Derived helpers
# ---------------------------------------------------------------------------
@@ -88,7 +164,7 @@ def get_worker_api_key() -> str | None:
# Worker-specific subscription / env var
if worker_llm.get("use_claude_code_subscription"):
try:
from framework.runner.runner import get_claude_code_token
from framework.loader.agent_loader import get_claude_code_token
token = get_claude_code_token()
if token:
@@ -98,7 +174,7 @@ def get_worker_api_key() -> str | None:
if worker_llm.get("use_codex_subscription"):
try:
from framework.runner.runner import get_codex_token
from framework.loader.agent_loader import get_codex_token
token = get_codex_token()
if token:
@@ -108,7 +184,7 @@ def get_worker_api_key() -> str | None:
if worker_llm.get("use_kimi_code_subscription"):
try:
from framework.runner.runner import get_kimi_code_token
from framework.loader.agent_loader import get_kimi_code_token
token = get_kimi_code_token()
if token:
@@ -118,7 +194,7 @@ def get_worker_api_key() -> str | None:
if worker_llm.get("use_antigravity_subscription"):
try:
from framework.runner.runner import get_antigravity_token
from framework.loader.agent_loader import get_antigravity_token
token = get_antigravity_token()
if token:
@@ -174,7 +250,7 @@ def get_worker_llm_extra_kwargs() -> dict[str, Any]:
"User-Agent": "CodexBar",
}
try:
from framework.runner.runner import get_codex_account_id
from framework.loader.agent_loader import get_codex_account_id
account_id = get_codex_account_id()
if account_id:
@@ -186,6 +262,8 @@ def get_worker_llm_extra_kwargs() -> dict[str, Any]:
"store": False,
"allowed_openai_params": ["store"],
}
if worker_llm.get("provider") == "ollama":
return {"num_ctx": worker_llm.get("num_ctx", 16384)}
return {}
@@ -219,22 +297,43 @@ def get_max_context_tokens() -> int:
return get_hive_config().get("llm", {}).get("max_context_tokens", DEFAULT_MAX_CONTEXT_TOKENS)
def get_api_keys() -> list[str] | None:
"""Return a list of API keys if ``api_keys`` is configured, else ``None``.
This supports key-pool rotation: configure multiple keys in
``~/.hive/configuration.json`` under ``llm.api_keys`` and the
:class:`~framework.llm.key_pool.KeyPool` will rotate through them.
"""
llm = get_hive_config().get("llm", {})
keys = llm.get("api_keys")
if keys and isinstance(keys, list) and len(keys) > 0:
return [k for k in keys if k] # filter empties
return None
def get_api_key() -> str | None:
"""Return the API key, supporting env var, Claude Code subscription, Codex, and ZAI Code.
Priority:
0. Explicit key pool (``api_keys`` list) -- returns first key for
single-key callers; full pool available via :func:`get_api_keys`.
1. Claude Code subscription (``use_claude_code_subscription: true``)
reads the OAuth token from ``~/.claude/.credentials.json``.
2. Codex subscription (``use_codex_subscription: true``)
reads the OAuth token from macOS Keychain or ``~/.codex/auth.json``.
3. Environment variable named in ``api_key_env_var``.
"""
# If an explicit key pool is configured, use the first key.
pool_keys = get_api_keys()
if pool_keys:
return pool_keys[0]
llm = get_hive_config().get("llm", {})
# Claude Code subscription: read OAuth token directly
if llm.get("use_claude_code_subscription"):
try:
from framework.runner.runner import get_claude_code_token
from framework.loader.agent_loader import get_claude_code_token
token = get_claude_code_token()
if token:
@@ -245,7 +344,7 @@ def get_api_key() -> str | None:
# Codex subscription: read OAuth token from Keychain / auth.json
if llm.get("use_codex_subscription"):
try:
from framework.runner.runner import get_codex_token
from framework.loader.agent_loader import get_codex_token
token = get_codex_token()
if token:
@@ -256,7 +355,7 @@ def get_api_key() -> str | None:
# Kimi Code subscription: read API key from ~/.kimi/config.toml
if llm.get("use_kimi_code_subscription"):
try:
from framework.runner.runner import get_kimi_code_token
from framework.loader.agent_loader import get_kimi_code_token
token = get_kimi_code_token()
if token:
@@ -267,7 +366,7 @@ def get_api_key() -> str | None:
# Antigravity subscription: read OAuth token from accounts JSON
if llm.get("use_antigravity_subscription"):
try:
from framework.runner.runner import get_antigravity_token
from framework.loader.agent_loader import get_antigravity_token
token = get_antigravity_token()
if token:
@@ -278,8 +377,12 @@ def get_api_key() -> str | None:
# Standard env-var path (covers ZAI Code and all API-key providers)
api_key_env_var = llm.get("api_key_env_var")
if api_key_env_var:
return os.environ.get(api_key_env_var)
return None
key = os.environ.get(api_key_env_var)
if key:
return key
# Credential store fallback — BYOK keys stored via the UI
return _get_api_key_from_credential_store(llm.get("provider", ""))
# OAuth credentials for Antigravity are fetched from the opencode-antigravity-auth project.
@@ -302,9 +405,7 @@ def _fetch_antigravity_credentials() -> tuple[str | None, str | None]:
import urllib.request
try:
req = urllib.request.Request(
_ANTIGRAVITY_CREDENTIALS_URL, headers={"User-Agent": "Hive/1.0"}
)
req = urllib.request.Request(_ANTIGRAVITY_CREDENTIALS_URL, headers={"User-Agent": "Hive/1.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
content = resp.read().decode("utf-8")
id_match = re.search(r'ANTIGRAVITY_CLIENT_ID\s*=\s*"([^"]+)"', content)
@@ -420,7 +521,7 @@ def get_llm_extra_kwargs() -> dict[str, Any]:
"User-Agent": "CodexBar",
}
try:
from framework.runner.runner import get_codex_account_id
from framework.loader.agent_loader import get_codex_account_id
account_id = get_codex_account_id()
if account_id:
@@ -432,6 +533,11 @@ def get_llm_extra_kwargs() -> dict[str, Any]:
"store": False,
"allowed_openai_params": ["store"],
}
if llm.get("provider") == "ollama":
# Pass num_ctx to Ollama so it doesn't silently truncate the ~9.5k Queen prompt.
# Ollama's default num_ctx is only 2048. We set it to 16384 here so LiteLLM
# passes it through as a provider-specific option.
return {"num_ctx": llm.get("num_ctx", 16384)}
return {}
+4
View File
@@ -51,6 +51,7 @@ from .key_storage import (
from .models import (
CredentialDecryptionError,
CredentialError,
CredentialExpiredError,
CredentialKey,
CredentialKeyNotFoundError,
CredentialNotFoundError,
@@ -84,6 +85,7 @@ from .template import TemplateResolver
from .validation import (
CredentialStatus,
CredentialValidationResult,
compute_unavailable_tools,
ensure_credential_key_env,
validate_agent_credentials,
)
@@ -136,6 +138,7 @@ __all__ = [
"CredentialNotFoundError",
"CredentialKeyNotFoundError",
"CredentialRefreshError",
"CredentialExpiredError",
"CredentialValidationError",
"CredentialDecryptionError",
# Key storage (bootstrap credentials)
@@ -148,6 +151,7 @@ __all__ = [
# Validation
"ensure_credential_key_env",
"validate_agent_credentials",
"compute_unavailable_tools",
"CredentialStatus",
"CredentialValidationResult",
# Interactive setup
+2 -6
View File
@@ -332,9 +332,7 @@ class AdenCredentialClient:
last_error = e
if attempt < self.config.retry_attempts - 1:
delay = self.config.retry_delay * (2**attempt)
logger.warning(
f"Aden request failed (attempt {attempt + 1}), retrying in {delay}s: {e}"
)
logger.warning(f"Aden request failed (attempt {attempt + 1}), retrying in {delay}s: {e}")
time.sleep(delay)
else:
raise AdenClientError(f"Failed to connect to Aden server: {e}") from e
@@ -347,9 +345,7 @@ class AdenCredentialClient:
):
raise
raise AdenClientError(
f"Request failed after {self.config.retry_attempts} attempts"
) from last_error
raise AdenClientError(f"Request failed after {self.config.retry_attempts} attempts") from last_error
def list_integrations(self) -> list[AdenIntegrationInfo]:
"""
+2 -6
View File
@@ -192,9 +192,7 @@ class AdenSyncProvider(CredentialProvider):
f"Visit: {e.reauthorization_url or 'your Aden dashboard'}"
) from e
raise CredentialRefreshError(
f"Failed to refresh credential '{credential.id}': {e}"
) from e
raise CredentialRefreshError(f"Failed to refresh credential '{credential.id}': {e}") from e
except AdenClientError as e:
logger.error(f"Aden client error for '{credential.id}': {e}")
@@ -206,9 +204,7 @@ class AdenSyncProvider(CredentialProvider):
logger.warning(f"Aden unavailable, using cached token for '{credential.id}'")
return credential
raise CredentialRefreshError(
f"Aden server unavailable and token expired for '{credential.id}'"
) from e
raise CredentialRefreshError(f"Aden server unavailable and token expired for '{credential.id}'") from e
def validate(self, credential: CredentialObject) -> bool:
"""
+14 -3
View File
@@ -168,9 +168,7 @@ class AdenCachedStorage(CredentialStorage):
if rid != credential_id:
result = self._load_by_id(rid)
if result is not None:
logger.info(
f"Loaded credential '{credential_id}' via provider index (id='{rid}')"
)
logger.info(f"Loaded credential '{credential_id}' via provider index (id='{rid}')")
return result
# Direct lookup (exact credential_id match)
@@ -199,6 +197,19 @@ class AdenCachedStorage(CredentialStorage):
if local_cred is None:
return None
# Skip Aden fetch for credentials not managed by Aden (BYOK credentials).
# Only OAuth credentials synced from Aden are in the provider index.
# BYOK credentials like anthropic, brave_search are local-only.
# Also check the _aden_managed flag on the credential itself.
is_aden_managed = (
credential_id in self._provider_index
or any(credential_id in ids for ids in self._provider_index.values())
or (local_cred is not None and local_cred.keys.get("_aden_managed") is not None)
)
if not is_aden_managed:
logger.debug(f"Credential '{credential_id}' is local-only, skipping Aden refresh")
return local_cred
# Try to refresh stale local credential from Aden
try:
aden_cred = self._aden_provider.fetch_from_aden(credential_id)
@@ -493,9 +493,7 @@ class TestAdenCachedStorage:
assert loaded is not None
assert loaded.keys["access_token"].value.get_secret_value() == "cached-token"
def test_load_from_aden_when_stale(
self, cached_storage, local_storage, provider, mock_client, aden_response
):
def test_load_from_aden_when_stale(self, cached_storage, local_storage, provider, mock_client, aden_response):
"""Test load fetches from Aden when cache is stale."""
# Create stale cached credential
cred = CredentialObject(
@@ -521,9 +519,7 @@ class TestAdenCachedStorage:
assert loaded is not None
assert loaded.keys["access_token"].value.get_secret_value() == "test-access-token"
def test_load_falls_back_to_stale_when_aden_fails(
self, cached_storage, local_storage, provider, mock_client
):
def test_load_falls_back_to_stale_when_aden_fails(self, cached_storage, local_storage, provider, mock_client):
"""Test load falls back to stale cache when Aden fails."""
# Create stale cached credential
cred = CredentialObject(
+23
View File
@@ -333,6 +333,29 @@ class CredentialRefreshError(CredentialError):
pass
class CredentialExpiredError(CredentialError):
"""Raised when a credential is expired and refresh has failed.
Carries the metadata an agent (or the tool runner) needs to surface a
reauth request to the user without having to look anything else up.
"""
def __init__(
self,
credential_id: str,
message: str,
*,
provider: str | None = None,
alias: str | None = None,
help_url: str | None = None,
):
self.credential_id = credential_id
self.provider = provider
self.alias = alias
self.help_url = help_url
super().__init__(message)
class CredentialValidationError(CredentialError):
"""Raised when credential validation fails."""
@@ -95,9 +95,7 @@ class BaseOAuth2Provider(CredentialProvider):
self._client = httpx.Client(timeout=self.config.request_timeout)
except ImportError as e:
raise ImportError(
"OAuth2 provider requires 'httpx'. Install with: uv pip install httpx"
) from e
raise ImportError("OAuth2 provider requires 'httpx'. Install with: uv pip install httpx") from e
return self._client
def _close_client(self) -> None:
@@ -311,8 +309,7 @@ class BaseOAuth2Provider(CredentialProvider):
except OAuth2Error as e:
if e.error == "invalid_grant":
raise CredentialRefreshError(
f"Refresh token for '{credential.id}' is invalid or revoked. "
"Re-authorization required."
f"Refresh token for '{credential.id}' is invalid or revoked. Re-authorization required."
) from e
raise CredentialRefreshError(f"Failed to refresh '{credential.id}': {e}") from e
@@ -422,9 +419,7 @@ class BaseOAuth2Provider(CredentialProvider):
if response.status_code != 200 or "error" in response_data:
error = response_data.get("error", "unknown_error")
description = response_data.get("error_description", response.text)
raise OAuth2Error(
error=error, description=description, status_code=response.status_code
)
raise OAuth2Error(error=error, description=description, status_code=response.status_code)
return OAuth2Token.from_token_response(response_data)
@@ -158,9 +158,7 @@ class TokenLifecycleManager:
"""
# Run in executor to avoid blocking
loop = asyncio.get_event_loop()
token = await loop.run_in_executor(
None, lambda: self.provider.client_credentials_grant(scopes=scopes)
)
token = await loop.run_in_executor(None, lambda: self.provider.client_credentials_grant(scopes=scopes))
self._save_token_to_store(token)
self._cached_token = token
@@ -100,9 +100,7 @@ class ZohoOAuth2Provider(BaseOAuth2Provider):
)
super().__init__(config, provider_id="zoho_crm_oauth2")
self._accounts_domain = base
self._api_domain = (
api_domain or os.getenv("ZOHO_API_DOMAIN", "https://www.zohoapis.com")
).rstrip("/")
self._api_domain = (api_domain or os.getenv("ZOHO_API_DOMAIN", "https://www.zohoapis.com")).rstrip("/")
@property
def supported_types(self) -> list[CredentialType]:
+23 -13
View File
@@ -36,7 +36,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from framework.graph import NodeSpec
from framework.orchestrator import NodeSpec
logger = logging.getLogger(__name__)
@@ -268,9 +268,7 @@ class CredentialSetupSession:
self._print(f"{Colors.YELLOW}Initializing credential store...{Colors.NC}")
try:
generate_and_save_credential_key()
self._print(
f"{Colors.GREEN}✓ Encryption key saved to ~/.hive/secrets/credential_key{Colors.NC}"
)
self._print(f"{Colors.GREEN}✓ Encryption key saved to ~/.hive/secrets/credential_key{Colors.NC}")
return True
except Exception as e:
self._print(f"{Colors.RED}Failed to initialize credential store: {e}{Colors.NC}")
@@ -449,9 +447,7 @@ class CredentialSetupSession:
logger.warning("Unexpected error exporting credential to env", exc_info=True)
return True
else:
self._print(
f"{Colors.YELLOW}{cred.credential_name} not found in Aden account.{Colors.NC}"
)
self._print(f"{Colors.YELLOW}{cred.credential_name} not found in Aden account.{Colors.NC}")
self._print("Please connect this integration on https://hive.adenhq.com first.")
return False
except Exception as e:
@@ -533,7 +529,9 @@ class CredentialSetupSession:
def load_agent_nodes(agent_path: str | Path) -> list:
"""Load NodeSpec list from an agent's agent.py or agent.json.
"""Load NodeSpec list from an agent directory.
Checks agent.json (declarative) first, then agent.py (legacy).
Args:
agent_path: Path to agent directory.
@@ -542,16 +540,28 @@ def load_agent_nodes(agent_path: str | Path) -> list:
List of NodeSpec objects (empty list if agent can't be loaded).
"""
agent_path = Path(agent_path)
agent_json_file = agent_path / "agent.json"
agent_py = agent_path / "agent.py"
agent_json = agent_path / "agent.json"
if agent_py.exists():
if agent_json_file.exists():
return _load_nodes_from_json_declarative(agent_json_file)
elif agent_py.exists():
return _load_nodes_from_python_agent(agent_path)
elif agent_json.exists():
return _load_nodes_from_json_agent(agent_json)
return []
def _load_nodes_from_json_declarative(agent_json: Path) -> list:
"""Load nodes from a declarative JSON agent."""
try:
from framework.loader.agent_loader import load_agent_config
data = json.loads(agent_json.read_text(encoding="utf-8"))
graph, _ = load_agent_config(data)
return list(graph.nodes)
except Exception:
return []
def _load_nodes_from_python_agent(agent_path: Path) -> list:
"""Load nodes from a Python-based agent."""
import importlib.util
@@ -590,7 +600,7 @@ def _load_nodes_from_json_agent(agent_json: Path) -> list:
with open(agent_json, encoding="utf-8-sig") as f:
data = json.load(f)
from framework.graph import NodeSpec
from framework.orchestrator import NodeSpec
nodes_data = data.get("graph", {}).get("nodes", [])
nodes = []
+156 -36
View File
@@ -136,8 +136,7 @@ class EncryptedFileStorage(CredentialStorage):
from cryptography.fernet import Fernet
except ImportError as e:
raise ImportError(
"Encrypted storage requires 'cryptography'. "
"Install with: uv pip install cryptography"
"Encrypted storage requires 'cryptography'. Install with: uv pip install cryptography"
) from e
self.base_path = Path(base_path or self.DEFAULT_PATH).expanduser()
@@ -161,6 +160,14 @@ class EncryptedFileStorage(CredentialStorage):
self._fernet = Fernet(self._key)
# Rebuild the metadata index from disk if it's missing or older than
# the current index schema. The index is a developer-readable JSON
# snapshot of the encrypted store; the .enc files remain authoritative.
try:
self._maybe_rebuild_index()
except Exception:
logger.debug("Initial index rebuild failed (non-fatal)", exc_info=True)
def _ensure_dirs(self) -> None:
"""Create directory structure."""
(self.base_path / "credentials").mkdir(parents=True, exist_ok=True)
@@ -186,8 +193,8 @@ class EncryptedFileStorage(CredentialStorage):
with open(cred_path, "wb") as f:
f.write(encrypted)
# Update index
self._update_index(credential.id, "save", credential.credential_type.value)
# Update developer-readable index
self._index_upsert(credential)
logger.debug(f"Saved encrypted credential '{credential.id}'")
def load(self, credential_id: str) -> CredentialObject | None:
@@ -205,9 +212,7 @@ class EncryptedFileStorage(CredentialStorage):
json_bytes = self._fernet.decrypt(encrypted)
data = json.loads(json_bytes.decode("utf-8-sig"))
except Exception as e:
raise CredentialDecryptionError(
f"Failed to decrypt credential '{credential_id}': {e}"
) from e
raise CredentialDecryptionError(f"Failed to decrypt credential '{credential_id}': {e}") from e
# Deserialize
return self._deserialize_credential(data)
@@ -217,7 +222,7 @@ class EncryptedFileStorage(CredentialStorage):
cred_path = self._cred_path(credential_id)
if cred_path.exists():
cred_path.unlink()
self._update_index(credential_id, "delete")
self._index_remove(credential_id)
logger.debug(f"Deleted credential '{credential_id}'")
return True
return False
@@ -258,33 +263,151 @@ class EncryptedFileStorage(CredentialStorage):
return CredentialObject.model_validate(data)
def _update_index(
self,
credential_id: str,
operation: str,
credential_type: str | None = None,
) -> None:
"""Update the metadata index."""
index_path = self.base_path / "metadata" / "index.json"
# ------------------------------------------------------------------
# Developer-readable metadata index
#
# The index lives at ``<base_path>/metadata/index.json`` and mirrors what
# is in the encrypted store at a glance: credential id, provider, alias,
# identity, key names, timestamps, and earliest expiry. It contains NO
# secret values and is safe to share when filing a bug report. The .enc
# files remain authoritative — the index is purely for human inspection
# and for cheap ``list_all()`` enumeration.
#
# Schema version is bumped whenever the entry shape changes; the store
# rebuilds the index from the encrypted files on load when the on-disk
# version is older.
# ------------------------------------------------------------------
if index_path.exists():
with open(index_path, encoding="utf-8-sig") as f:
index = json.load(f)
else:
index = {"credentials": {}, "version": "1.0"}
INDEX_VERSION = "2.0"
INDEX_INTERNAL_KEY_NAMES = ("_alias", "_integration_type")
if operation == "save":
index["credentials"][credential_id] = {
"updated_at": datetime.now(UTC).isoformat(),
"type": credential_type,
}
elif operation == "delete":
index["credentials"].pop(credential_id, None)
def _index_path(self) -> Path:
return self.base_path / "metadata" / "index.json"
index["last_modified"] = datetime.now(UTC).isoformat()
def _read_index(self) -> dict[str, Any]:
"""Read the index from disk; return an empty skeleton if missing."""
path = self._index_path()
if not path.exists():
return {"version": self.INDEX_VERSION, "credentials": {}}
try:
with open(path, encoding="utf-8-sig") as f:
return json.load(f)
except Exception:
logger.debug("Failed to read credential index, starting fresh", exc_info=True)
return {"version": self.INDEX_VERSION, "credentials": {}}
with open(index_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2)
def _write_index(self, index: dict[str, Any]) -> None:
"""Write the index to disk with consistent envelope fields."""
index["version"] = self.INDEX_VERSION
index["store_path"] = str(self.base_path)
index["generated_at"] = datetime.now(UTC).isoformat()
path = self._index_path()
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2, sort_keys=False, default=str)
def _index_entry_for(self, credential: CredentialObject) -> dict[str, Any]:
"""Build a single index entry from a CredentialObject (no secrets)."""
# Visible key names: drop internal markers like _alias / _integration_type
# / _identity_* so the entry shows what's actually a credential key.
visible_keys = [
name
for name in credential.keys.keys()
if name not in self.INDEX_INTERNAL_KEY_NAMES and not name.startswith("_identity_")
]
# Earliest expiry across all keys (most likely the access_token).
earliest_expiry: datetime | None = None
for key in credential.keys.values():
if key.expires_at is None:
continue
if earliest_expiry is None or key.expires_at < earliest_expiry:
earliest_expiry = key.expires_at
return {
"credential_type": credential.credential_type.value,
"provider": credential.provider_type,
"alias": credential.alias,
"identity": credential.identity.to_dict(),
"key_names": sorted(visible_keys),
"created_at": credential.created_at.isoformat() if credential.created_at else None,
"updated_at": credential.updated_at.isoformat() if credential.updated_at else None,
"last_refreshed": (credential.last_refreshed.isoformat() if credential.last_refreshed else None),
"expires_at": earliest_expiry.isoformat() if earliest_expiry else None,
"auto_refresh": credential.auto_refresh,
"tags": list(credential.tags),
}
def _index_upsert(self, credential: CredentialObject) -> None:
"""Insert or update one credential entry in the index."""
try:
index = self._read_index()
if index.get("version") != self.INDEX_VERSION:
# Old schema — rebuild from disk so we don't blend formats.
self._rebuild_index()
return
credentials = index.setdefault("credentials", {})
credentials[credential.id] = self._index_entry_for(credential)
self._write_index(index)
except Exception:
logger.debug("Index upsert failed (non-fatal)", exc_info=True)
def _index_remove(self, credential_id: str) -> None:
"""Remove one credential entry from the index."""
try:
index = self._read_index()
if index.get("version") != self.INDEX_VERSION:
self._rebuild_index()
return
credentials = index.setdefault("credentials", {})
credentials.pop(credential_id, None)
self._write_index(index)
except Exception:
logger.debug("Index remove failed (non-fatal)", exc_info=True)
def _maybe_rebuild_index(self) -> None:
"""Rebuild the index if it's missing, malformed, or on an old schema.
Called once at startup. The check is cheap read the version field
and bail out if it matches. Encrypted files remain authoritative; this
only refreshes the developer-facing snapshot.
"""
path = self._index_path()
if path.exists():
try:
with open(path, encoding="utf-8-sig") as f:
index = json.load(f)
if index.get("version") == self.INDEX_VERSION:
return
except Exception:
pass # fall through to rebuild
self._rebuild_index()
def _rebuild_index(self) -> None:
"""Walk the encrypted credentials directory and rewrite a fresh index."""
cred_dir = self.base_path / "credentials"
if not cred_dir.is_dir():
return
entries: dict[str, Any] = {}
for cred_file in sorted(cred_dir.glob("*.enc")):
credential_id = cred_file.stem
try:
cred = self.load(credential_id)
except Exception:
logger.debug(
"Failed to load %s during index rebuild — skipping",
credential_id,
exc_info=True,
)
continue
if cred is None:
continue
entries[cred.id] = self._index_entry_for(cred)
index = {"credentials": entries}
self._write_index(index)
logger.info("Rebuilt credential index with %d entries", len(entries))
class EnvVarStorage(CredentialStorage):
@@ -351,8 +474,7 @@ class EnvVarStorage(CredentialStorage):
def save(self, credential: CredentialObject) -> None:
"""Cannot save to environment variables at runtime."""
raise NotImplementedError(
"EnvVarStorage is read-only. Set environment variables "
"externally or use EncryptedFileStorage."
"EnvVarStorage is read-only. Set environment variables externally or use EncryptedFileStorage."
)
def load(self, credential_id: str) -> CredentialObject | None:
@@ -372,9 +494,7 @@ class EnvVarStorage(CredentialStorage):
def delete(self, credential_id: str) -> bool:
"""Cannot delete environment variables at runtime."""
raise NotImplementedError(
"EnvVarStorage is read-only. Unset environment variables externally."
)
raise NotImplementedError("EnvVarStorage is read-only. Unset environment variables externally.")
def list_all(self) -> list[str]:
"""List credentials that are available in environment."""
+52 -11
View File
@@ -19,6 +19,7 @@ from typing import Any
from pydantic import SecretStr
from .models import (
CredentialExpiredError,
CredentialKey,
CredentialObject,
CredentialRefreshError,
@@ -123,9 +124,7 @@ class CredentialStore:
"""
return self._providers.get(provider_id)
def get_provider_for_credential(
self, credential: CredentialObject
) -> CredentialProvider | None:
def get_provider_for_credential(self, credential: CredentialObject) -> CredentialProvider | None:
"""
Get the appropriate provider for a credential.
@@ -177,6 +176,8 @@ class CredentialStore:
self,
credential_id: str,
refresh_if_needed: bool = True,
*,
raise_on_refresh_failure: bool = False,
) -> CredentialObject | None:
"""
Get a credential by ID.
@@ -184,6 +185,11 @@ class CredentialStore:
Args:
credential_id: The credential identifier
refresh_if_needed: If True, refresh expired credentials
raise_on_refresh_failure: If True, raise ``CredentialExpiredError``
when refresh fails instead of silently returning the stale
credential. Tool-execution call sites should pass True so the
agent gets a structured "reauth needed" signal rather than a
later 401 from the provider.
Returns:
CredentialObject or None if not found
@@ -193,7 +199,7 @@ class CredentialStore:
cached = self._get_from_cache(credential_id)
if cached is not None:
if refresh_if_needed and self._should_refresh(cached):
return self._refresh_credential(cached)
return self._refresh_credential(cached, raise_on_failure=raise_on_refresh_failure)
return cached
# Load from storage
@@ -203,30 +209,42 @@ class CredentialStore:
# Refresh if needed
if refresh_if_needed and self._should_refresh(credential):
credential = self._refresh_credential(credential)
credential = self._refresh_credential(credential, raise_on_failure=raise_on_refresh_failure)
# Cache
self._add_to_cache(credential)
return credential
def get_key(self, credential_id: str, key_name: str) -> str | None:
def get_key(
self,
credential_id: str,
key_name: str,
*,
raise_on_refresh_failure: bool = False,
) -> str | None:
"""
Convenience method to get a specific key value.
Args:
credential_id: The credential identifier
key_name: The key within the credential
raise_on_refresh_failure: See ``get_credential``.
Returns:
The key value or None if not found
"""
credential = self.get_credential(credential_id)
credential = self.get_credential(credential_id, raise_on_refresh_failure=raise_on_refresh_failure)
if credential is None:
return None
return credential.get_key(key_name)
def get(self, credential_id: str) -> str | None:
def get(
self,
credential_id: str,
*,
raise_on_refresh_failure: bool = False,
) -> str | None:
"""
Legacy compatibility: get the primary key value.
@@ -235,11 +253,12 @@ class CredentialStore:
Args:
credential_id: The credential identifier
raise_on_refresh_failure: See ``get_credential``.
Returns:
The primary key value or None
"""
credential = self.get_credential(credential_id)
credential = self.get_credential(credential_id, raise_on_refresh_failure=raise_on_refresh_failure)
if credential is None:
return None
return credential.get_default_key()
@@ -510,8 +529,20 @@ class CredentialStore:
return provider.should_refresh(credential)
def _refresh_credential(self, credential: CredentialObject) -> CredentialObject:
"""Refresh a credential using its provider."""
def _refresh_credential(
self,
credential: CredentialObject,
*,
raise_on_failure: bool = False,
) -> CredentialObject:
"""Refresh a credential using its provider.
When ``raise_on_failure`` is True, a refresh failure raises
``CredentialExpiredError`` carrying provider/alias/help_url metadata
for the caller (typically the tool runner) to surface a reauth
request. Otherwise, the stale credential is returned to preserve
legacy best-effort behavior.
"""
provider = self.get_provider_for_credential(credential)
if provider is None:
logger.warning(f"No provider found for credential '{credential.id}'")
@@ -530,6 +561,16 @@ class CredentialStore:
except CredentialRefreshError as e:
logger.error(f"Failed to refresh credential '{credential.id}': {e}")
if raise_on_failure:
raise CredentialExpiredError(
credential_id=credential.id,
message=(
f"OAuth token for '{credential.id}' is expired and "
f"refresh failed: {e}. Reauthorization required."
),
provider=credential.provider_type,
alias=credential.alias,
) from e
return credential
def refresh_credential(self, credential_id: str) -> CredentialObject | None:
+2 -6
View File
@@ -88,9 +88,7 @@ class TemplateResolver:
if key_name:
value = credential.get_key(key_name)
if value is None:
raise CredentialKeyNotFoundError(
f"Key '{key_name}' not found in credential '{cred_id}'"
)
raise CredentialKeyNotFoundError(f"Key '{key_name}' not found in credential '{cred_id}'")
else:
# Use default key
value = credential.get_default_key()
@@ -126,9 +124,7 @@ class TemplateResolver:
... })
{"Authorization": "Bearer ghp_xxx", "X-API-Key": "BSAKxxx"}
"""
return {
key: self.resolve(value, fail_on_missing) for key, value in header_templates.items()
}
return {key: self.resolve(value, fail_on_missing) for key, value in header_templates.items()}
def resolve_params(
self,
@@ -130,9 +130,7 @@ class TestCredentialObject:
# With access_token
cred2 = CredentialObject(
id="test",
keys={
"access_token": CredentialKey(name="access_token", value=SecretStr("token-value"))
},
keys={"access_token": CredentialKey(name="access_token", value=SecretStr("token-value"))},
)
assert cred2.get_default_key() == "token-value"
@@ -297,9 +295,7 @@ class TestEncryptedFileStorage:
key = Fernet.generate_key().decode()
with patch.dict(os.environ, {"HIVE_CREDENTIAL_KEY": key}):
storage = EncryptedFileStorage(temp_dir)
cred = CredentialObject(
id="test", keys={"k": CredentialKey(name="k", value=SecretStr("v"))}
)
cred = CredentialObject(id="test", keys={"k": CredentialKey(name="k", value=SecretStr("v"))})
storage.save(cred)
# Create new storage instance with same key
@@ -330,18 +326,10 @@ class TestCompositeStorage:
def test_read_from_primary(self):
"""Test reading from primary storage."""
primary = InMemoryStorage()
primary.save(
CredentialObject(
id="test", keys={"k": CredentialKey(name="k", value=SecretStr("primary"))}
)
)
primary.save(CredentialObject(id="test", keys={"k": CredentialKey(name="k", value=SecretStr("primary"))}))
fallback = InMemoryStorage()
fallback.save(
CredentialObject(
id="test", keys={"k": CredentialKey(name="k", value=SecretStr("fallback"))}
)
)
fallback.save(CredentialObject(id="test", keys={"k": CredentialKey(name="k", value=SecretStr("fallback"))}))
storage = CompositeStorage(primary, [fallback])
cred = storage.load("test")
@@ -353,11 +341,7 @@ class TestCompositeStorage:
"""Test fallback when credential not in primary."""
primary = InMemoryStorage()
fallback = InMemoryStorage()
fallback.save(
CredentialObject(
id="test", keys={"k": CredentialKey(name="k", value=SecretStr("fallback"))}
)
)
fallback.save(CredentialObject(id="test", keys={"k": CredentialKey(name="k", value=SecretStr("fallback"))}))
storage = CompositeStorage(primary, [fallback])
cred = storage.load("test")
@@ -393,9 +377,7 @@ class TestStaticProvider:
def test_refresh_returns_unchanged(self):
"""Test that refresh returns credential unchanged."""
provider = StaticProvider()
cred = CredentialObject(
id="test", keys={"k": CredentialKey(name="k", value=SecretStr("v"))}
)
cred = CredentialObject(id="test", keys={"k": CredentialKey(name="k", value=SecretStr("v"))})
refreshed = provider.refresh(cred)
assert refreshed.get_key("k") == "v"
@@ -403,9 +385,7 @@ class TestStaticProvider:
def test_validate_with_keys(self):
"""Test validation with keys present."""
provider = StaticProvider()
cred = CredentialObject(
id="test", keys={"k": CredentialKey(name="k", value=SecretStr("v"))}
)
cred = CredentialObject(id="test", keys={"k": CredentialKey(name="k", value=SecretStr("v"))})
assert provider.validate(cred)
@@ -606,9 +586,7 @@ class TestCredentialStore:
storage = InMemoryStorage()
store = CredentialStore(storage=storage, cache_ttl_seconds=60)
storage.save(
CredentialObject(id="test", keys={"k": CredentialKey(name="k", value=SecretStr("v"))})
)
storage.save(CredentialObject(id="test", keys={"k": CredentialKey(name="k", value=SecretStr("v"))}))
# First load
store.get_credential("test")
@@ -686,9 +664,7 @@ class TestOAuth2Module:
from core.framework.credentials.oauth2 import OAuth2Config, TokenPlacement
# Valid config
config = OAuth2Config(
token_url="https://example.com/token", client_id="id", client_secret="secret"
)
config = OAuth2Config(token_url="https://example.com/token", client_id="id", client_secret="secret")
assert config.token_url == "https://example.com/token"
# Missing token_url
+44 -20
View File
@@ -160,15 +160,9 @@ class CredentialValidationResult:
if aden_nc:
if missing or invalid:
lines.append("")
lines.append(
"Aden integrations not connected "
"(ADEN_API_KEY is set but OAuth tokens unavailable):\n"
)
lines.append("Aden integrations not connected (ADEN_API_KEY is set but OAuth tokens unavailable):\n")
for c in aden_nc:
lines.append(
f" {c.env_var} for {_label(c)}"
f"\n Connect this integration at hive.adenhq.com first."
)
lines.append(f" {c.env_var} for {_label(c)}\n Connect this integration at hive.adenhq.com first.")
lines.append("\nIf you've already set up credentials, restart your terminal to load them.")
return "\n".join(lines)
@@ -236,6 +230,45 @@ def _presync_aden_tokens(credential_specs: dict, *, force: bool = False) -> None
)
def compute_unavailable_tools(nodes: list) -> tuple[set[str], list[str]]:
"""Return (tool_names_to_drop, human_messages).
Runs credential validation *without* raising, collects every tool
bound to a failed credential (missing / invalid / Aden-not-connected
and no alternative provider available), and returns the set of tool
names that should be silently dropped from the worker's effective
tool list.
Use this at every worker-spawn preflight so missing credentials
filter tools out of the graph instead of hard-failing the whole
spawn. Only affects non-MCP tools the MCP admission gate
(``_build_mcp_admission_gate``) already handles MCP tools at
registration time.
"""
try:
result = validate_agent_credentials(nodes, verify=False, raise_on_error=False)
except Exception as exc:
logger.debug("compute_unavailable_tools: validation raised: %s", exc)
return set(), []
drop: set[str] = set()
messages: list[str] = []
for status in result.failed:
if not status.tools:
continue
drop.update(status.tools)
reason = "missing"
if status.aden_not_connected:
reason = "aden_not_connected"
elif status.available and status.valid is False:
reason = "invalid"
messages.append(
f"{status.env_var} ({reason}) → drops {len(status.tools)} tool(s): "
f"{', '.join(status.tools[:6])}" + (f" +{len(status.tools) - 6} more" if len(status.tools) > 6 else "")
)
return drop, messages
def validate_agent_credentials(
nodes: list,
quiet: bool = False,
@@ -292,9 +325,7 @@ def validate_agent_credentials(
if os.environ.get("ADEN_API_KEY"):
_presync_aden_tokens(CREDENTIAL_SPECS, force=force_refresh)
env_mapping = {
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
}
env_mapping = {(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()}
env_storage = EnvVarStorage(env_mapping=env_mapping)
if os.environ.get("HIVE_CREDENTIAL_KEY"):
storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()])
@@ -328,12 +359,7 @@ def validate_agent_credentials(
available = store.is_available(cred_id)
# Aden-not-connected: ADEN_API_KEY set, Aden-only cred, but integration missing
is_aden_nc = (
not available
and has_aden_key
and spec.aden_supported
and not spec.direct_api_key_supported
)
is_aden_nc = not available and has_aden_key and spec.aden_supported and not spec.direct_api_key_supported
status = CredentialStatus(
credential_name=cred_name,
@@ -451,9 +477,7 @@ def validate_agent_credentials(
identity_data = result.details.get("identity")
if identity_data and isinstance(identity_data, dict):
try:
cred_obj = store.get_credential(
status.credential_id, refresh_if_needed=False
)
cred_obj = store.get_credential(status.credential_id, refresh_if_needed=False)
if cred_obj:
cred_obj.set_identity(**identity_data)
store.save_credential(cred_obj)
-59
View File
@@ -1,59 +0,0 @@
"""Graph structures: Goals, Nodes, Edges, and Execution."""
from framework.graph.client_io import (
ActiveNodeClientIO,
ClientIOGateway,
InertNodeClientIO,
NodeClientIO,
)
from framework.graph.context_handoff import ContextHandoff, HandoffContext
from framework.graph.conversation import ConversationStore, Message, NodeConversation
from framework.graph.edge import DEFAULT_MAX_TOKENS, EdgeCondition, EdgeSpec, GraphSpec
from framework.graph.event_loop_node import (
EventLoopNode,
JudgeProtocol,
JudgeVerdict,
LoopConfig,
OutputAccumulator,
)
from framework.graph.executor import GraphExecutor
from framework.graph.goal import Constraint, Goal, GoalStatus, SuccessCriterion
from framework.graph.node import NodeContext, NodeProtocol, NodeResult, NodeSpec
__all__ = [
# Goal
"Goal",
"SuccessCriterion",
"Constraint",
"GoalStatus",
# Node
"NodeSpec",
"NodeContext",
"NodeResult",
"NodeProtocol",
# Edge
"EdgeSpec",
"EdgeCondition",
"GraphSpec",
"DEFAULT_MAX_TOKENS",
# Executor
"GraphExecutor",
# Conversation
"NodeConversation",
"ConversationStore",
"Message",
# Event Loop
"EventLoopNode",
"LoopConfig",
"OutputAccumulator",
"JudgeProtocol",
"JudgeVerdict",
# Context Handoff
"ContextHandoff",
"HandoffContext",
# Client I/O
"NodeClientIO",
"ActiveNodeClientIO",
"InertNodeClientIO",
"ClientIOGateway",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-129
View File
@@ -1,129 +0,0 @@
"""GCU (browser automation) node type constants.
A ``gcu`` node is an ``event_loop`` node with two automatic enhancements:
1. A canonical browser best-practices system prompt is prepended.
2. All tools from the GCU MCP server are auto-included.
No new ``NodeProtocol`` subclass the ``gcu`` type is purely a declarative
signal processed by the runner and executor at setup time.
"""
# ---------------------------------------------------------------------------
# MCP server identity
# ---------------------------------------------------------------------------
GCU_SERVER_NAME = "gcu-tools"
"""Name used to identify the GCU MCP server in ``mcp_servers.json``."""
GCU_MCP_SERVER_CONFIG: dict = {
"name": GCU_SERVER_NAME,
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
"cwd": "../../tools",
"description": "GCU tools for browser automation",
}
"""Default stdio config for the GCU MCP server (relative to exports/<agent>/)."""
# ---------------------------------------------------------------------------
# Browser best-practices system prompt
# ---------------------------------------------------------------------------
GCU_BROWSER_SYSTEM_PROMPT = """\
# Browser Automation Best Practices
Follow these rules for reliable, efficient browser interaction.
## Reading Pages
- ALWAYS prefer `browser_snapshot` over `browser_get_text("body")`
it returns a compact ~1-5 KB accessibility tree vs 100+ KB of raw HTML.
- Interaction tools (`browser_click`, `browser_type`, `browser_fill`,
`browser_scroll`, etc.) return a page snapshot automatically in their
result. Use it to decide your next action do NOT call
`browser_snapshot` separately after every action.
Only call `browser_snapshot` when you need a fresh view without
performing an action, or after setting `auto_snapshot=false`.
- Do NOT use `browser_screenshot` to read text use
`browser_snapshot` for that (compact, searchable, fast).
- DO use `browser_screenshot` when you need visual context:
charts, images, canvas elements, layout verification, or when
the snapshot doesn't capture what you need.
- Only fall back to `browser_get_text` for extracting specific
small elements by CSS selector.
## Navigation & Waiting
- `browser_navigate` and `browser_open` already wait for the page to
load (`domcontentloaded`). Do NOT call `browser_wait` with no
arguments after navigation it wastes time.
Only use `browser_wait` when you need a *specific element* or *text*
to appear (pass `selector` or `text`).
- NEVER re-navigate to the same URL after scrolling
this resets your scroll position and loses loaded content.
## Scrolling
- Use large scroll amounts ~2000 when loading more content
sites like twitter and linkedin have lazy loading for paging.
- The scroll result includes a snapshot automatically no need to call
`browser_snapshot` separately.
## Batching Actions
- You can call multiple tools in a single turn they execute in parallel.
ALWAYS batch independent actions together. Examples:
- Fill multiple form fields in one turn.
- Navigate + snapshot in one turn.
- Click + scroll if targeting different elements.
- When batching, set `auto_snapshot=false` on all but the last action
to avoid redundant snapshots.
- Aim for 3-5 tool calls per turn minimum. One tool call per turn is
wasteful.
## Error Recovery
- If a tool fails, retry once with the same approach.
- If it fails a second time, STOP retrying and switch approach.
- If `browser_snapshot` fails try `browser_get_text` with a
specific small selector as fallback.
- If `browser_open` fails or page seems stale `browser_stop`,
then `browser_start`, then retry.
## Tab Management
**Close tabs as soon as you are done with them** not only at the end of the task.
After reading or extracting data from a tab, close it immediately.
**Decision rules:**
- Finished reading/extracting from a tab? `browser_close(target_id=...)`
- Completed a multi-tab workflow? `browser_close_finished()` to clean up all your tabs
- More than 3 tabs open? stop and close finished ones before opening more
- Popup appeared that you didn't need? → close it immediately
**Origin awareness:** `browser_tabs` returns an `origin` field for each tab:
- `"agent"` you opened it; you own it; close it when done
- `"popup"` opened by a link or script; close after extracting what you need
- `"startup"` or `"user"` leave these alone unless the task requires it
**Cleanup tools:**
- `browser_close(target_id=...)` close one specific tab
- `browser_close_finished()` close all your agent/popup tabs (safe: leaves startup/user tabs)
- `browser_close_all()` close everything except the active tab (use only for full reset)
**Multi-tab workflow pattern:**
1. Open background tabs with `browser_open(url=..., background=true)` to stay on current tab
2. Process each tab and close it with `browser_close` when done
3. When the full workflow completes, call `browser_close_finished()` to confirm cleanup
4. Check `browser_tabs` at any point it shows `origin` and `age_seconds` per tab
Never accumulate tabs. Treat every tab you open as a resource you must free.
## Login & Auth Walls
- If you see a "Log in" or "Sign up" prompt instead of expected
content, report the auth wall immediately do NOT attempt to log in.
- Check for cookie consent banners and dismiss them if they block content.
## Efficiency
- Minimize tool calls combine actions where possible.
- When a snapshot result is saved to a spillover file, use
`run_command` with grep to extract specific data rather than
re-reading the full file.
- Call `set_output` in the same turn as your last browser action
when possible don't waste a turn.
"""
-373
View File
@@ -1,373 +0,0 @@
"""Prompt composition for continuous agent mode.
Composes the three-layer system prompt (onion model) and generates
transition markers inserted into the conversation at phase boundaries.
Layer 1 Identity (static, defined at agent level, never changes):
"You are a thorough research agent. You prefer clarity over jargon..."
Layer 2 Narrative (auto-generated from conversation/memory state):
"We've finished scoping the project. The user wants to focus on..."
Layer 3 Focus (per-node system_prompt, reframed as focus directive):
"Your current attention: synthesize findings into a report..."
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from framework.graph.edge import GraphSpec
from framework.graph.node import NodeSpec, SharedMemory
logger = logging.getLogger(__name__)
# Injected into every worker node's system prompt so the LLM understands
# it is one step in a multi-node pipeline and should not overreach.
EXECUTION_SCOPE_PREAMBLE = (
"EXECUTION SCOPE: You are one node in a multi-step workflow graph. "
"Focus ONLY on the task described in your instructions below. "
"Call set_output() for each of your declared output keys, then stop. "
"Do NOT attempt work that belongs to other nodes — the framework "
"routes data between nodes automatically."
)
def _with_datetime(prompt: str) -> str:
"""Append current datetime with local timezone to a system prompt."""
local = datetime.now().astimezone()
stamp = f"Current date and time: {local.strftime('%Y-%m-%d %H:%M %Z (UTC%z)')}"
return f"{prompt}\n\n{stamp}" if prompt else stamp
def build_accounts_prompt(
accounts: list[dict[str, Any]],
tool_provider_map: dict[str, str] | None = None,
node_tool_names: list[str] | None = None,
) -> str:
"""Build a prompt section describing connected accounts.
When tool_provider_map is provided, produces structured output grouped
by provider with tool mapping, so the LLM knows which ``account`` value
to pass to which tool.
When node_tool_names is also provided, filters to only show providers
whose tools overlap with the node's tool list.
Args:
accounts: List of account info dicts from
CredentialStoreAdapter.get_all_account_info().
tool_provider_map: Mapping of tool_name -> provider_name
(e.g. {"gmail_list_messages": "google"}).
node_tool_names: Tool names available to the current node.
When provided, only providers with matching tools are shown.
Returns:
Formatted accounts block, or empty string if no accounts.
"""
if not accounts:
return ""
# Flat format (backward compat) when no tool mapping provided
if tool_provider_map is None:
lines = [
"Connected accounts (use the alias as the `account` parameter "
"when calling tools to target a specific account):"
]
for acct in accounts:
provider = acct.get("provider", "unknown")
alias = acct.get("alias", "unknown")
identity = acct.get("identity", {})
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
lines.append(f"- {provider}/{alias}{detail}")
return "\n".join(lines)
# --- Structured format: group by provider with tool mapping ---
# Invert tool_provider_map to provider -> [tools]
provider_tools: dict[str, list[str]] = {}
for tool_name, provider in tool_provider_map.items():
provider_tools.setdefault(provider, []).append(tool_name)
# Filter to relevant providers based on node tools
node_tool_set = set(node_tool_names) if node_tool_names else None
# Group accounts by provider
provider_accounts: dict[str, list[dict[str, Any]]] = {}
for acct in accounts:
provider = acct.get("provider", "unknown")
provider_accounts.setdefault(provider, []).append(acct)
sections: list[str] = ["Connected accounts:"]
for provider, acct_list in provider_accounts.items():
tools_for_provider = sorted(provider_tools.get(provider, []))
# If node tools specified, only show providers with overlapping tools
if node_tool_set is not None:
relevant_tools = [t for t in tools_for_provider if t in node_tool_set]
if not relevant_tools:
continue
tools_for_provider = relevant_tools
# Local-only providers: tools read from env vars, no account= routing
all_local = all(a.get("source") == "local" for a in acct_list)
# Provider header with tools
display_name = provider.replace("_", " ").title()
if tools_for_provider and not all_local:
tools_str = ", ".join(tools_for_provider)
sections.append(f'\n{display_name} (use account="<alias>" with: {tools_str}):')
elif tools_for_provider and all_local:
tools_str = ", ".join(tools_for_provider)
sections.append(f"\n{display_name} (tools: {tools_str}):")
else:
sections.append(f"\n{display_name}:")
# Account entries
for acct in acct_list:
alias = acct.get("alias", "unknown")
identity = acct.get("identity", {})
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
source_tag = " [local]" if acct.get("source") == "local" else ""
sections.append(f" - {provider}/{alias}{detail}{source_tag}")
# If filtering removed all providers, return empty
if len(sections) <= 1:
return ""
return "\n".join(sections)
def compose_system_prompt(
identity_prompt: str | None,
focus_prompt: str | None,
narrative: str | None = None,
accounts_prompt: str | None = None,
skills_catalog_prompt: str | None = None,
protocols_prompt: str | None = None,
execution_preamble: str | None = None,
node_type_preamble: str | None = None,
) -> str:
"""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.
execution_preamble: EXECUTION_SCOPE_PREAMBLE for worker nodes
(prepended before focus so the LLM knows its pipeline scope).
node_type_preamble: Node-type-specific preamble, e.g. GCU browser
best-practices prompt (prepended before focus).
Returns:
Composed system prompt with all layers present, plus current datetime.
"""
parts: list[str] = []
# Layer 1: Identity (always first, anchors the personality)
if identity_prompt:
parts.append(identity_prompt)
# Accounts (semi-static, deployment-specific)
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}")
# Execution scope preamble (worker nodes — tells the LLM it is one
# step in a multi-node pipeline and should not overreach)
if execution_preamble:
parts.append(f"\n{execution_preamble}")
# Node-type preamble (e.g. GCU browser best-practices)
if node_type_preamble:
parts.append(f"\n{node_type_preamble}")
# Layer 3: Focus (current phase directive)
if focus_prompt:
parts.append(f"\n--- Current Focus ---\n{focus_prompt}")
return _with_datetime("\n".join(parts) if parts else "")
def build_narrative(
memory: SharedMemory,
execution_path: list[str],
graph: GraphSpec,
) -> str:
"""Build Layer 2 (narrative) from structured state.
Deterministic no LLM call. Reads SharedMemory and execution path
to describe what has happened so far. Cheap and fast.
Args:
memory: Current shared memory state.
execution_path: List of node IDs visited so far.
graph: Graph spec (for node names/descriptions).
Returns:
Narrative string describing the session state.
"""
parts: list[str] = []
# Describe execution path
if execution_path:
phase_descriptions: list[str] = []
for node_id in execution_path:
node_spec = graph.get_node(node_id)
if node_spec:
phase_descriptions.append(f"- {node_spec.name}: {node_spec.description}")
else:
phase_descriptions.append(f"- {node_id}")
parts.append("Phases completed:\n" + "\n".join(phase_descriptions))
# Describe key memory values (skip very long values)
all_memory = memory.read_all()
if all_memory:
memory_lines: list[str] = []
for key, value in all_memory.items():
if value is None:
continue
val_str = str(value)
if len(val_str) > 200:
val_str = val_str[:200] + "..."
memory_lines.append(f"- {key}: {val_str}")
if memory_lines:
parts.append("Current state:\n" + "\n".join(memory_lines))
return "\n\n".join(parts) if parts else ""
def build_transition_marker(
previous_node: NodeSpec,
next_node: NodeSpec,
memory: SharedMemory,
cumulative_tool_names: list[str],
data_dir: Path | str | None = None,
adapt_content: str | None = None,
) -> str:
"""Build a 'State of the World' transition marker.
Inserted into the conversation as a user message at phase boundaries.
Gives the LLM full situational awareness: what happened, what's stored,
what tools are available, and what to focus on next.
Args:
previous_node: NodeSpec of the phase just completed.
next_node: NodeSpec of the phase about to start.
memory: Current shared memory state.
cumulative_tool_names: All tools available (cumulative set).
data_dir: Path to spillover data directory.
adapt_content: Agent working memory (adapt.md) content.
Returns:
Transition marker message text.
"""
sections: list[str] = []
# Header
sections.append(f"--- PHASE TRANSITION: {previous_node.name}{next_node.name} ---")
# What just completed
sections.append(f"\nCompleted: {previous_node.name}")
sections.append(f" {previous_node.description}")
# Outputs in memory — use file references for large values so the
# next node loads full data from disk instead of seeing truncated
# inline previews that look deceptively complete.
all_memory = memory.read_all()
if all_memory:
memory_lines: list[str] = []
for key, value in all_memory.items():
if value is None:
continue
val_str = str(value)
if len(val_str) > 300 and data_dir:
# Auto-spill large transition values to data files
import json as _json
data_path = Path(data_dir)
data_path.mkdir(parents=True, exist_ok=True)
ext = ".json" if isinstance(value, (dict, list)) else ".txt"
filename = f"output_{key}{ext}"
try:
write_content = (
_json.dumps(value, indent=2, ensure_ascii=False)
if isinstance(value, (dict, list))
else str(value)
)
(data_path / filename).write_text(write_content, encoding="utf-8")
file_size = (data_path / filename).stat().st_size
val_str = (
f"[Saved to '{filename}' ({file_size:,} bytes). "
f"Use load_data(filename='{filename}') to access.]"
)
except Exception:
val_str = val_str[:300] + "..."
elif len(val_str) > 300:
val_str = val_str[:300] + "..."
memory_lines.append(f" {key}: {val_str}")
if memory_lines:
sections.append("\nOutputs available:\n" + "\n".join(memory_lines))
# Files in data directory
if data_dir:
data_path = Path(data_dir)
if data_path.exists():
files = sorted(data_path.iterdir())
if files:
file_lines = [
f" {f.name} ({f.stat().st_size:,} bytes)" for f in files if f.is_file()
]
if file_lines:
sections.append(
"\nData files (use load_data to access):\n" + "\n".join(file_lines)
)
# Agent working memory
if adapt_content:
sections.append(f"\n--- Agent Memory ---\n{adapt_content}")
# Available tools
if cumulative_tool_names:
sections.append("\nAvailable tools: " + ", ".join(sorted(cumulative_tool_names)))
# Next phase
sections.append(f"\nNow entering: {next_node.name}")
sections.append(f" {next_node.description}")
if next_node.output_keys:
sections.append(
f"\nYour ONLY job in this phase: complete the task above and call "
f"set_output() for {next_node.output_keys}. Do NOT do work that "
f"belongs to later phases."
)
# Reflection prompt (engineered metacognition)
sections.append(
"\nBefore proceeding, briefly reflect: what went well in the "
"previous phase? Are there any gaps or surprises worth noting?"
)
sections.append("\n--- END TRANSITION ---")
return "\n".join(sections)
+15
View File
@@ -0,0 +1,15 @@
"""Host layer -- how agents are triggered and hosted."""
from framework.host.colony_runtime import ( # noqa: F401
ColonyConfig,
ColonyRuntime,
StreamEventBus,
TriggerSpec,
)
from framework.host.event_bus import AgentEvent, EventBus, EventType # noqa: F401
from framework.host.worker import ( # noqa: F401
Worker,
WorkerInfo,
WorkerResult,
WorkerStatus,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -94,12 +94,12 @@ class EventType(StrEnum):
TOOL_CALL_STARTED = "tool_call_started"
TOOL_CALL_COMPLETED = "tool_call_completed"
# Client I/O (client_facing=True nodes only)
# Queen/user interaction events
CLIENT_OUTPUT_DELTA = "client_output_delta"
CLIENT_INPUT_REQUESTED = "client_input_requested"
CLIENT_INPUT_RECEIVED = "client_input_received"
# Internal node observability (client_facing=False nodes)
# Internal node observability
NODE_INTERNAL_OUTPUT = "node_internal_output"
NODE_INPUT_BLOCKED = "node_input_blocked"
NODE_STALLED = "node_stalled"
@@ -108,12 +108,21 @@ class EventType(StrEnum):
# Judge decisions (implicit judge in event loop nodes)
JUDGE_VERDICT = "judge_verdict"
# Output tracking
OUTPUT_KEY_SET = "output_key_set"
# Retry / edge tracking
# Retry tracking
NODE_RETRY = "node_retry"
EDGE_TRAVERSED = "edge_traversed"
# Stream-health observability. Split from NODE_RETRY so the UI can
# distinguish "slow TTFT on a huge context" (healthy, just slow) from
# "stream went silent mid-generation" (probable stall) from "we nudged
# the model to continue" (recovery), which NODE_RETRY used to conflate.
STREAM_TTFT_EXCEEDED = "stream_ttft_exceeded"
STREAM_INACTIVE = "stream_inactive"
STREAM_NUDGE_SENT = "stream_nudge_sent"
TOOL_CALL_REPLAY_DETECTED = "tool_call_replay_detected"
# Worker agent lifecycle
WORKER_COMPLETED = "worker_completed"
WORKER_FAILED = "worker_failed"
# Context management
CONTEXT_COMPACTED = "context_compacted"
@@ -128,28 +137,22 @@ class EventType(StrEnum):
# Escalation (agent requests handoff to queen)
ESCALATION_REQUESTED = "escalation_requested"
# Worker health monitoring
WORKER_ESCALATION_TICKET = "worker_escalation_ticket"
QUEEN_INTERVENTION_REQUESTED = "queen_intervention_requested"
# Execution resurrection (auto-restart on non-fatal failure)
EXECUTION_RESURRECTED = "execution_resurrected"
# Worker lifecycle (session manager → frontend)
WORKER_LOADED = "worker_loaded"
# Colony lifecycle (session manager → frontend)
WORKER_COLONY_LOADED = "worker_colony_loaded"
# Queen create_colony tool finished forking; carries colony_name +
# path so the frontend can render a system message linking to the
# new colony page at /colony/{colony_name}.
COLONY_CREATED = "colony_created"
CREDENTIALS_REQUIRED = "credentials_required"
# Draft graph (planning phase — lightweight graph preview)
DRAFT_GRAPH_UPDATED = "draft_graph_updated"
# Flowchart map updated (after reconciliation with runtime graph)
FLOWCHART_MAP_UPDATED = "flowchart_map_updated"
# Queen phase changes (building <-> staging <-> running)
# Queen phase changes (working <-> reviewing)
QUEEN_PHASE_CHANGED = "queen_phase_changed"
# Queen thinking hook — persona selected for the current building session
QUEEN_PERSONA_SELECTED = "queen_persona_selected"
# Queen identity — which queen profile was selected for this session
QUEEN_IDENTITY_SELECTED = "queen_identity_selected"
# Subagent reports (one-way progress updates from sub-agents)
SUBAGENT_REPORT = "subagent_report"
@@ -174,7 +177,7 @@ class AgentEvent:
data: dict[str, Any] = field(default_factory=dict)
timestamp: datetime = field(default_factory=datetime.now)
correlation_id: str | None = None # For tracking related events
graph_id: str | None = None # Which graph emitted this event (multi-graph sessions)
colony_id: str | None = None # Which colony emitted this event
run_id: str | None = None # Unique ID per trigger() invocation — used for run dividers
def to_dict(self) -> dict:
@@ -187,7 +190,7 @@ class AgentEvent:
"data": self.data,
"timestamp": self.timestamp.isoformat(),
"correlation_id": self.correlation_id,
"graph_id": self.graph_id,
"colony_id": self.colony_id,
}
if self.run_id is not None:
d["run_id"] = self.run_id
@@ -208,7 +211,7 @@ class Subscription:
filter_stream: str | None = None # Only receive events from this stream
filter_node: str | None = None # Only receive events from this node
filter_execution: str | None = None # Only receive events from this execution
filter_graph: str | None = None # Only receive events from this graph
filter_colony: str | None = None # Only receive events from this colony
class EventBus:
@@ -390,7 +393,7 @@ class EventBus:
filter_stream: str | None = None,
filter_node: str | None = None,
filter_execution: str | None = None,
filter_graph: str | None = None,
filter_colony: str | None = None,
) -> str:
"""
Subscribe to events.
@@ -401,7 +404,7 @@ class EventBus:
filter_stream: Only receive events from this stream
filter_node: Only receive events from this node
filter_execution: Only receive events from this execution
filter_graph: Only receive events from this graph
filter_colony: Only receive events from this colony
Returns:
Subscription ID (use to unsubscribe)
@@ -416,7 +419,7 @@ class EventBus:
filter_stream=filter_stream,
filter_node=filter_node,
filter_execution=filter_execution,
filter_graph=filter_graph,
filter_colony=filter_colony,
)
self._subscriptions[sub_id] = subscription
@@ -452,11 +455,7 @@ class EventBus:
# iteration values. Without this, live SSE would use raw iterations
# while events.jsonl would use offset iterations, causing ID collisions
# on the frontend when replaying after cold resume.
if (
self._session_log_iteration_offset
and isinstance(event.data, dict)
and "iteration" in event.data
):
if self._session_log_iteration_offset and isinstance(event.data, dict) and "iteration" in event.data:
offset = self._session_log_iteration_offset
event.data = {**event.data, "iteration": event.data["iteration"] + offset}
@@ -518,23 +517,41 @@ class EventBus:
if subscription.filter_execution and subscription.filter_execution != event.execution_id:
return False
# Check graph filter
if subscription.filter_graph and subscription.filter_graph != event.graph_id:
# Check colony filter
if subscription.filter_colony and subscription.filter_colony != event.colony_id:
return False
return True
# Per-handler wall-clock timeout. A subscriber that deadlocks or
# blocks on slow I/O would otherwise freeze the publisher (and via
# ``await publish(...)`` any coroutine that emits events) indefinitely.
# 15 s is generous for legitimate handlers and cheap to tune later.
_HANDLER_TIMEOUT_SECONDS: float = 15.0
async def _execute_handlers(
self,
event: AgentEvent,
handlers: list[EventHandler],
) -> None:
"""Execute handlers concurrently with rate limiting."""
"""Execute handlers concurrently with rate limiting + hard timeout."""
async def run_handler(handler: EventHandler) -> None:
async with self._semaphore:
try:
await handler(event)
await asyncio.wait_for(
handler(event),
timeout=self._HANDLER_TIMEOUT_SECONDS,
)
except TimeoutError:
handler_name = getattr(handler, "__qualname__", repr(handler))
logger.error(
"EventBus handler %s exceeded %.0fs on event %s — dropping; "
"fix the handler or the publisher will stall",
handler_name,
self._HANDLER_TIMEOUT_SECONDS,
getattr(event.type, "name", event.type),
)
except Exception:
logger.exception(f"Handler error for {event.type}")
@@ -879,7 +896,7 @@ class EventBus:
iteration: int | None = None,
inner_turn: int = 0,
) -> None:
"""Emit client output delta event (client_facing=True nodes)."""
"""Emit user-facing output delta for interactive queen turns."""
data: dict = {"content": content, "snapshot": snapshot, "inner_turn": inner_turn}
if iteration is not None:
data["iteration"] = iteration
@@ -897,24 +914,22 @@ class EventBus:
self,
stream_id: str,
node_id: str,
prompt: str = "",
execution_id: str | None = None,
options: list[str] | None = None,
questions: list[dict] | None = None,
) -> None:
"""Emit client input requested event (client_facing=True nodes).
"""Emit a user-input request for interactive queen turns.
Args:
options: Optional predefined choices for the user (1-3 items).
The frontend appends an "Other" free-text option
automatically.
questions: Optional list of question dicts for multi-question
batches (from ask_user_multiple). Each dict has id,
prompt, and optional options.
questions: Optional list of question dicts from ``ask_user``.
Each dict has ``id``, ``prompt``, and optional ``options``
(2-3 predefined choices). The frontend renders the
QuestionWidget for a single-entry list and the
MultiQuestionWidget for 2+ entries. Free-text asks (no
options) stream the prompt separately as a chat message;
auto-block turns have no questions at all and fall back
to the normal text input.
"""
data: dict[str, Any] = {"prompt": prompt}
if options:
data["options"] = options
data: dict[str, Any] = {}
if questions:
data["questions"] = questions
await self.publish(
@@ -936,7 +951,7 @@ class EventBus:
content: str,
execution_id: str | None = None,
) -> None:
"""Emit node internal output event (client_facing=False nodes)."""
"""Emit node internal output for non-user-facing execution."""
await self.publish(
AgentEvent(
type=EventType.NODE_INTERNAL_OUTPUT,
@@ -1029,24 +1044,6 @@ class EventBus:
)
)
async def emit_output_key_set(
self,
stream_id: str,
node_id: str,
key: str,
execution_id: str | None = None,
) -> None:
"""Emit output key set event."""
await self.publish(
AgentEvent(
type=EventType.OUTPUT_KEY_SET,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"key": key},
)
)
async def emit_node_retry(
self,
stream_id: str,
@@ -1071,29 +1068,142 @@ class EventBus:
)
)
async def emit_edge_traversed(
async def emit_stream_ttft_exceeded(
self,
stream_id: str,
source_node: str,
target_node: str,
edge_condition: str = "",
node_id: str,
ttft_seconds: float,
limit_seconds: float,
execution_id: str | None = None,
) -> None:
"""Emit edge traversed event."""
"""Emit when a stream stayed silent past the TTFT budget (no first event)."""
await self.publish(
AgentEvent(
type=EventType.EDGE_TRAVERSED,
type=EventType.STREAM_TTFT_EXCEEDED,
stream_id=stream_id,
node_id=source_node,
node_id=node_id,
execution_id=execution_id,
data={
"source_node": source_node,
"target_node": target_node,
"edge_condition": edge_condition,
"ttft_seconds": ttft_seconds,
"limit_seconds": limit_seconds,
},
)
)
async def emit_stream_inactive(
self,
stream_id: str,
node_id: str,
idle_seconds: float,
limit_seconds: float,
execution_id: str | None = None,
) -> None:
"""Emit when a stream that had produced events went silent past budget."""
await self.publish(
AgentEvent(
type=EventType.STREAM_INACTIVE,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={
"idle_seconds": idle_seconds,
"limit_seconds": limit_seconds,
},
)
)
async def emit_stream_nudge_sent(
self,
stream_id: str,
node_id: str,
reason: str,
nudge_count: int,
execution_id: str | None = None,
) -> None:
"""Emit when the continue-nudge was injected (recovery, not retry)."""
await self.publish(
AgentEvent(
type=EventType.STREAM_NUDGE_SENT,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={
"reason": reason,
"nudge_count": nudge_count,
},
)
)
async def emit_tool_call_replay_detected(
self,
stream_id: str,
node_id: str,
tool_name: str,
prior_seq: int,
execution_id: str | None = None,
) -> None:
"""Emit when the model is about to re-execute a prior successful call."""
await self.publish(
AgentEvent(
type=EventType.TOOL_CALL_REPLAY_DETECTED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={
"tool_name": tool_name,
"prior_seq": prior_seq,
},
)
)
async def emit_worker_completed(
self,
stream_id: str,
node_id: str,
worker_id: str,
success: bool,
output: dict[str, Any],
activations: list[dict[str, Any]] | None = None,
execution_id: str | None = None,
**extra_data: Any,
) -> None:
"""Emit worker completed event with outgoing activations."""
data: dict[str, Any] = {
"worker_id": worker_id,
"success": success,
"output": output,
"activations": activations or [],
**extra_data,
}
await self.publish(
AgentEvent(
type=EventType.WORKER_COMPLETED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data=data,
)
)
async def emit_worker_failed(
self,
stream_id: str,
node_id: str,
worker_id: str,
error: str,
execution_id: str | None = None,
) -> None:
"""Emit worker failed event."""
await self.publish(
AgentEvent(
type=EventType.WORKER_FAILED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"worker_id": worker_id, "error": error},
)
)
async def emit_execution_paused(
self,
stream_id: str,
@@ -1160,60 +1270,24 @@ class EventBus:
reason: str = "",
context: str = "",
execution_id: str | None = None,
request_id: str | None = None,
) -> None:
"""Emit escalation requested event (agent wants queen)."""
"""Emit escalation requested event (agent wants queen).
``request_id`` is a caller-supplied handle used by the queen to
address its reply back to the specific escalation. When omitted the
event still fires but the queen cannot route a targeted reply.
"""
await self.publish(
AgentEvent(
type=EventType.ESCALATION_REQUESTED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"reason": reason, "context": context},
)
)
async def emit_worker_escalation_ticket(
self,
stream_id: str,
node_id: str,
ticket: dict,
execution_id: str | None = None,
) -> None:
"""Emitted when worker shows a degradation pattern."""
await self.publish(
AgentEvent(
type=EventType.WORKER_ESCALATION_TICKET,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"ticket": ticket},
)
)
async def emit_queen_intervention_requested(
self,
stream_id: str,
node_id: str,
ticket_id: str,
analysis: str,
severity: str,
queen_graph_id: str,
queen_stream_id: str,
execution_id: str | None = None,
) -> None:
"""Emitted by queen when she decides the operator should be involved."""
await self.publish(
AgentEvent(
type=EventType.QUEEN_INTERVENTION_REQUESTED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={
"ticket_id": ticket_id,
"analysis": analysis,
"severity": severity,
"queen_graph_id": queen_graph_id,
"queen_stream_id": queen_stream_id,
"request_id": request_id,
"reason": reason,
"context": context,
},
)
)
@@ -1295,7 +1369,7 @@ class EventBus:
stream_id: str | None = None,
node_id: str | None = None,
execution_id: str | None = None,
graph_id: str | None = None,
colony_id: str | None = None,
timeout: float | None = None,
) -> AgentEvent | None:
"""
@@ -1306,7 +1380,7 @@ class EventBus:
stream_id: Filter by stream
node_id: Filter by node
execution_id: Filter by execution
graph_id: Filter by graph
colony_id: Filter by colony
timeout: Maximum time to wait (seconds)
Returns:
@@ -1327,7 +1401,7 @@ class EventBus:
filter_stream=stream_id,
filter_node=node_id,
filter_execution=execution_id,
filter_graph=graph_id,
filter_colony=colony_id,
)
try:
@@ -16,20 +16,20 @@ from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from framework.graph.checkpoint_config import CheckpointConfig
from framework.graph.executor import ExecutionResult, GraphExecutor
from framework.runtime.event_bus import EventBus
from framework.runtime.shared_state import IsolationLevel, SharedStateManager
from framework.runtime.stream_runtime import StreamRuntime, StreamRuntimeAdapter
from framework.host.event_bus import EventBus
from framework.host.shared_state import IsolationLevel, SharedBufferManager
from framework.host.stream_runtime import StreamDecisionTracker, StreamRuntimeAdapter
from framework.orchestrator.checkpoint_config import CheckpointConfig
from framework.orchestrator.orchestrator import ExecutionResult, Orchestrator
if TYPE_CHECKING:
from framework.graph.edge import GraphSpec
from framework.graph.goal import Goal
from framework.host.event_bus import AgentEvent
from framework.host.outcome_aggregator import OutcomeAggregator
from framework.llm.provider import LLMProvider, Tool
from framework.runtime.event_bus import AgentEvent
from framework.runtime.outcome_aggregator import OutcomeAggregator
from framework.orchestrator.edge import GraphSpec
from framework.orchestrator.goal import Goal
from framework.storage.concurrent import ConcurrentStorage
from framework.storage.session_store import SessionStore
@@ -48,6 +48,8 @@ class ExecutionAlreadyRunningError(RuntimeError):
logger = logging.getLogger(__name__)
CancelExecutionResult = Literal["cancelled", "cancelling", "not_found"]
class GraphScopedEventBus(EventBus):
"""Proxy that stamps ``graph_id`` on every published event.
@@ -130,10 +132,10 @@ class ExecutionContext:
run_id: str | None = None # Unique ID per trigger() invocation
started_at: datetime = field(default_factory=datetime.now)
completed_at: datetime | None = None
status: str = "pending" # pending, running, completed, failed, paused
status: str = "pending" # pending, running, cancelling, completed, failed, paused, cancelled
class ExecutionStream:
class ExecutionManager:
"""
Manages concurrent executions for a single entry point.
@@ -170,9 +172,9 @@ class ExecutionStream:
entry_spec: EntryPointSpec,
graph: "GraphSpec",
goal: "Goal",
state_manager: SharedStateManager,
state_manager: SharedBufferManager,
storage: "ConcurrentStorage",
outcome_aggregator: "OutcomeAggregator",
outcome_aggregator: "OutcomeAggregator | None" = None,
event_bus: "EventBus | None" = None,
llm: "LLMProvider | None" = None,
tools: list["Tool"] | None = None,
@@ -189,6 +191,9 @@ class ExecutionStream:
skills_catalog_prompt: str = "",
protocols_prompt: str = "",
skill_dirs: list[str] | None = None,
context_warn_ratio: float | None = None,
batch_init_nudge: str | None = None,
dynamic_memory_provider_factory: Callable[[str], Callable[[], str] | None] | None = None,
):
"""
Initialize execution stream.
@@ -215,6 +220,8 @@ class ExecutionStream:
skills_catalog_prompt: Available skills catalog for system prompt
protocols_prompt: Default skill operational protocols for system prompt
skill_dirs: Skill base directories for Tier 3 resource access
context_warn_ratio: Token usage ratio to trigger DS-13 preservation warning
batch_init_nudge: System prompt nudge for DS-12 batch auto-detection
"""
self.stream_id = stream_id
self.entry_spec = entry_spec
@@ -239,6 +246,9 @@ class ExecutionStream:
self._skills_catalog_prompt = skills_catalog_prompt
self._protocols_prompt = protocols_prompt
self._skill_dirs: list[str] = skill_dirs or []
self._context_warn_ratio: float | None = context_warn_ratio
self._batch_init_nudge: str | None = batch_init_nudge
self._dynamic_memory_provider_factory = dynamic_memory_provider_factory
_es_logger = logging.getLogger(__name__)
if protocols_prompt:
@@ -254,16 +264,15 @@ class ExecutionStream:
)
# Create stream-scoped runtime
self._runtime = StreamRuntime(
self._runtime = StreamDecisionTracker(
stream_id=stream_id,
storage=storage,
outcome_aggregator=outcome_aggregator,
)
# Execution tracking
self._active_executions: dict[str, ExecutionContext] = {}
self._execution_tasks: dict[str, asyncio.Task] = {}
self._active_executors: dict[str, GraphExecutor] = {}
self._active_executors: dict[str, Orchestrator] = {}
self._cancel_reasons: dict[str, str] = {}
self._execution_results: OrderedDict[str, ExecutionResult] = OrderedDict()
self._execution_result_times: dict[str, float] = {}
@@ -293,7 +302,7 @@ class ExecutionStream:
# Emit stream started event
if self._scoped_event_bus:
from framework.runtime.event_bus import AgentEvent, EventType
from framework.host.event_bus import AgentEvent, EventType
await self._scoped_event_bus.publish(
AgentEvent(
@@ -308,6 +317,22 @@ class ExecutionStream:
"""Return IDs of all currently active executions."""
return list(self._active_executions.keys())
def _get_blocking_execution_ids_locked(self) -> list[str]:
"""Return executions that still block a replacement from starting.
An execution continues to block replacement until its task has
terminated and the task's final cleanup has removed its bookkeeping.
This is intentional: a timed-out cancellation does not mean the old
task is harmless. If it is still alive, it can still write shared
session state, so letting a replacement start would guarantee
overlapping mutations on the same session.
"""
blocking_ids: list[str] = list(self._active_executions.keys())
for execution_id, task in self._execution_tasks.items():
if not task.done() and execution_id not in self._active_executions:
blocking_ids.append(execution_id)
return blocking_ids
@property
def agent_idle_seconds(self) -> float:
"""Seconds since the last agent activity (LLM call, tool call, node transition).
@@ -351,7 +376,7 @@ class ExecutionStream:
Each entry is ``{"node_id": ..., "execution_id": ...}``.
The currently executing node is placed first so that
``inject_worker_message`` targets the active node, not a stale one.
``inject_message`` targets the active node, not a stale one.
"""
injectable: list[dict[str, str]] = []
current_first: list[dict[str, str]] = []
@@ -389,15 +414,22 @@ class ExecutionStream:
async def stop(self) -> None:
"""Stop the execution stream and cancel active executions."""
if not self._running:
return
async with self._lock:
if not self._running:
return
self._running = False
self._running = False
# Cancel all active executions
tasks_to_wait = []
for _, task in self._execution_tasks.items():
if not task.done():
# Cancel all active executions, but keep bookkeeping until each
# task reaches its own cleanup path.
tasks_to_wait: list[asyncio.Task] = []
for execution_id, task in self._execution_tasks.items():
if task.done():
continue
ctx = self._active_executions.get(execution_id)
if ctx is not None:
ctx.status = "cancelling"
self._cancel_reasons.setdefault(execution_id, "Execution cancelled")
task.cancel()
tasks_to_wait.append(task)
@@ -411,14 +443,11 @@ class ExecutionStream:
len(pending),
)
self._execution_tasks.clear()
self._active_executions.clear()
logger.info(f"ExecutionStream '{self.stream_id}' stopped")
# Emit stream stopped event
if self._scoped_event_bus:
from framework.runtime.event_bus import AgentEvent, EventType
from framework.host.event_bus import AgentEvent, EventType
await self._scoped_event_bus.publish(
AgentEvent(
@@ -445,9 +474,7 @@ class ExecutionStream:
for executor in self._active_executors.values():
node = executor.node_registry.get(node_id)
if node is not None and hasattr(node, "inject_event"):
await node.inject_event(
content, is_client_input=is_client_input, image_content=image_content
)
await node.inject_event(content, is_client_input=is_client_input, image_content=image_content)
return True
return False
@@ -544,6 +571,14 @@ class ExecutionStream:
correlation_id = execution_id
# Create execution context
effective_run_id = None
if session_state:
existing_run_id = session_state.get("run_id")
if isinstance(existing_run_id, str) and existing_run_id:
effective_run_id = existing_run_id
if effective_run_id is None:
effective_run_id = run_id
ctx = ExecutionContext(
id=execution_id,
correlation_id=correlation_id,
@@ -552,16 +587,20 @@ class ExecutionStream:
input_data=input_data,
isolation_level=self.entry_spec.get_isolation_level(),
session_state=session_state,
run_id=run_id,
run_id=effective_run_id,
)
async with self._lock:
if not self._running:
raise RuntimeError(f"ExecutionStream '{self.stream_id}' is not running")
blocking_ids = self._get_blocking_execution_ids_locked()
if blocking_ids:
raise ExecutionAlreadyRunningError(self.stream_id, blocking_ids)
self._active_executions[execution_id] = ctx
self._completion_events[execution_id] = asyncio.Event()
# Start execution task
task = asyncio.create_task(self._run_execution(ctx))
self._execution_tasks[execution_id] = task
self._execution_tasks[execution_id] = asyncio.create_task(self._run_execution(ctx))
logger.debug(f"Queued execution {execution_id} for stream {self.stream_id}")
return execution_id
@@ -633,7 +672,7 @@ class ExecutionStream:
self._write_run_event(execution_id, ctx.run_id, "run_started")
# Create execution-scoped memory
self._state_manager.create_memory(
self._state_manager.create_buffer(
execution_id=execution_id,
stream_id=self.stream_id,
isolation=ctx.isolation_level,
@@ -652,11 +691,9 @@ class ExecutionStream:
# Create per-execution runtime logger
runtime_logger = None
if self._runtime_log_store:
from framework.runtime.runtime_logger import RuntimeLogger
from framework.tracker.runtime_logger import RuntimeLogger
runtime_logger = RuntimeLogger(
store=self._runtime_log_store, agent_id=self.graph.id
)
runtime_logger = RuntimeLogger(store=self._runtime_log_store, agent_id=self.graph.id)
# Derive storage from session_store (graph-specific for secondary
# graphs) so that all files — conversations, state, checkpoints,
@@ -681,12 +718,7 @@ class ExecutionStream:
# forward so the next attempt resumes at the failed node.
while True:
# Create executor for this execution.
# Each execution gets its own storage under sessions/{exec_id}/
# so conversations, spillover, and data files are all scoped
# to this execution. The executor sets data_dir via execution
# context (contextvars) so data tools and spillover share the
# same session-scoped directory.
executor = GraphExecutor(
executor = Orchestrator(
runtime=runtime_adapter,
llm=self._llm,
tools=self._tools,
@@ -694,6 +726,7 @@ class ExecutionStream:
event_bus=self._scoped_event_bus,
stream_id=self.stream_id,
execution_id=execution_id,
run_id=ctx.run_id or "",
storage_path=exec_storage,
runtime_logger=runtime_logger,
loop_config=self.graph.loop_config,
@@ -703,6 +736,13 @@ class ExecutionStream:
skills_catalog_prompt=self._skills_catalog_prompt,
protocols_prompt=self._protocols_prompt,
skill_dirs=self._skill_dirs,
context_warn_ratio=self._context_warn_ratio,
batch_init_nudge=self._batch_init_nudge,
dynamic_memory_provider=(
self._dynamic_memory_provider_factory(execution_id)
if self._dynamic_memory_provider_factory is not None
else None
),
)
# Track executor so inject_input() can reach EventLoopNode instances
self._active_executors[execution_id] = executor
@@ -739,7 +779,7 @@ class ExecutionStream:
# Emit resurrection event
if self._scoped_event_bus:
from framework.runtime.event_bus import AgentEvent, EventType
from framework.host.event_bus import AgentEvent, EventType
await self._scoped_event_bus.publish(
AgentEvent(
@@ -869,9 +909,7 @@ class ExecutionStream:
if has_result and result.paused_at:
await self._write_session_state(execution_id, ctx, result=result)
else:
await self._write_session_state(
execution_id, ctx, error="Execution cancelled"
)
await self._write_session_state(execution_id, ctx, error="Execution cancelled")
# Emit SSE event so the frontend knows the execution stopped.
# The executor does NOT emit on CancelledError, so there is no
@@ -961,7 +999,10 @@ class ExecutionStream:
return
import json as _json
session_dir = self._session_store.get_session_path(execution_id)
try:
session_dir = self._session_store.get_session_path(execution_id)
except ValueError:
return
runs_file = session_dir / "runs.jsonl"
now = datetime.now()
record = {
@@ -1033,6 +1074,7 @@ class ExecutionStream:
agent_id=self.graph.id,
entry_point=self.entry_spec.id,
)
state.current_run_id = ctx.run_id
else:
# Create initial state — when resuming, preserve the previous
# execution's progress so crashes don't lose track of state.
@@ -1063,8 +1105,9 @@ class ExecutionStream:
updated_at=now,
),
progress=progress,
memory=ss.get("memory", {}),
data_buffer=ss.get("data_buffer", ss.get("memory", {})),
input_data=ctx.input_data,
current_run_id=ctx.run_id,
)
# Handle error case
@@ -1090,7 +1133,7 @@ class ExecutionStream:
Each stream only executes from its own entry_node, but the full
graph must validate with all entry points accounted for.
"""
from framework.graph.edge import GraphSpec
from framework.orchestrator.edge import GraphSpec
# Merge entry points: this stream's entry + original graph's primary
# entry + any other entry points. This ensures all nodes are
@@ -1166,7 +1209,7 @@ class ExecutionStream:
"""Get execution context."""
return self._active_executions.get(execution_id)
async def cancel_execution(self, execution_id: str, *, reason: str | None = None) -> bool:
async def cancel_execution(self, execution_id: str, *, reason: str | None = None) -> CancelExecutionResult:
"""
Cancel a running execution.
@@ -1177,21 +1220,38 @@ class ExecutionStream:
provided, defaults to "Execution cancelled".
Returns:
True if cancelled, False if not found
"cancelled" if the task fully exited within the grace period,
"cancelling" if cancellation was requested but the task is still
shutting down, or "not_found" if no active task exists.
"""
task = self._execution_tasks.get(execution_id)
if task and not task.done():
async with self._lock:
task = self._execution_tasks.get(execution_id)
if task is None or task.done():
return "not_found"
# Store the reason so the CancelledError handler can use it
# when emitting the pause/fail event.
self._cancel_reasons[execution_id] = reason or "Execution cancelled"
ctx = self._active_executions.get(execution_id)
if ctx is not None:
ctx.status = "cancelling"
task.cancel()
# Wait briefly for the task to finish. Don't block indefinitely —
# the task may be stuck in a long LLM API call that doesn't
# respond to cancellation quickly. The cancellation is already
# requested; the task will clean up in the background.
done, _ = await asyncio.wait({task}, timeout=5.0)
return True
return False
# Wait briefly for the task to finish. Don't block indefinitely —
# the task may be stuck in a long LLM API call that doesn't
# respond to cancellation quickly.
done, _ = await asyncio.wait({task}, timeout=5.0)
if not done:
# Keep bookkeeping in place until the task's own finally block runs.
# We intentionally do not add deferred cleanup keyed by execution_id
# here because resumed executions reuse the same id; a delayed pop
# could otherwise delete bookkeeping that belongs to the new run.
logger.warning(
"Execution %s did not finish within cancel timeout; leaving bookkeeping in place until task exit",
execution_id,
)
return "cancelling"
return "cancelled"
# === STATS AND MONITORING ===
+9
View File
@@ -0,0 +1,9 @@
"""State isolation level enum."""
from enum import StrEnum
class IsolationLevel(StrEnum):
ISOLATED = "isolated"
SHARED = "shared"
SYNCHRONIZED = "synchronized"
+21
View File
@@ -0,0 +1,21 @@
"""Stub — outcome aggregator removed in colony refactor."""
from framework.schemas.goal import Goal
class OutcomeAggregator:
def __init__(self, goal: Goal, event_bus=None):
self._goal = goal
self._event_bus = event_bus
def record_decision(self, **kwargs):
pass
def record_outcome(self, **kwargs):
pass
def evaluate_goal_progress(self):
return {"progress": 0.0, "criteria_status": {}}
def get_stats(self):
return {"total_decisions": 0, "total_outcomes": 0}

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