Compare commits

...

167 Commits

Author SHA1 Message Date
Timothy af720bb569 fix: stop worker 2026-04-10 15:40:35 -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
Timothy 4aa2358211 feat: doppelganger wiring 2026-04-09 18:04:45 -07:00
Timothy 4be61ebfc7 refactor: shatter the eld*n ring 2026-04-09 16:57:43 -07:00
Timothy df43f36385 fix: issues 2026-04-09 12:59:42 -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 95f1d1abcd feat: browser automated test 2026-04-03 07:31:10 -07:00
Timothy e0cd16b92b fix: trailing white spaces 2026-04-02 16:43:23 -07:00
Timothy 71a71beca7 feat: extension browser tools 2026-04-02 15:58:52 -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
Timothy c7e85aa9f5 fix: redo gcu tools for extension based browser use 2026-04-02 12:07:24 -07:00
Timothy 8f042b7ca5 feat: browser extension 2026-04-02 11:59:57 -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
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
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
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
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
Sundaram Kumar Jha d1cbfd1e54 fix(security): enforce safe_eval execution timeout 2026-04-01 23:35:41 +05:30
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
SRI LIKHITA ADRU 1e2e6e03dd Merge branch 'main' into fix/stream-transient-retry-cap 2026-03-20 09:08:14 -04:00
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
473 changed files with 42399 additions and 41287 deletions
+17
View File
@@ -1,4 +1,21 @@
{
"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 ':*)"
],
"additionalDirectories": [
"/home/timothy/.hive/skills/writing-hive-skills"
]
},
"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 |
| 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 execCommand |
| 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,113 @@
#!/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,152 @@
#!/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,253 @@
#!/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,162 @@
#!/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,267 @@
#!/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(
" ⚠ NOT IMPLEMENTED: selector param ignored"
f" (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,333 @@
#!/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())
+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 }})
+3
View File
@@ -70,6 +70,8 @@ tmp/
temp/
exports/*
exports.old*
artifacts/*
.claude/settings.local.json
@@ -79,3 +81,4 @@ 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"}
+16
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
+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
+1 -13
View File
@@ -51,7 +51,7 @@ https://github.com/user-attachments/assets/bf10edc3-06ba-48b6-98ba-d069b15fb69d
## Who Is Hive For?
Hive is the harness layer for teams moving AI agents from prototype to production. Models are getting better on their own — the bottleneck is the infrastructure around them: state management, failure recovery, cost control, and observability.
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:
@@ -194,18 +194,6 @@ flowchart LR
style V6 fill:#fff,stroke:#ed8c00,stroke-width:1px,color:#cc5d00
```
### The Hive Advantage
| Typical Agent Frameworks | Hive |
| -------------------------- | -------------------------------------- |
| Focus on model orchestration | **Production harness**: state, recovery, observability |
| 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
-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.buffer.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.buffer.read("greeting") or ""
result = greeting.upper()
ctx.buffer.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 -60
View File
@@ -1,66 +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 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",
# 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
@@ -59,9 +59,12 @@ class Message:
return {"role": "user", "content": self.content}
if self.role == "assistant":
d: dict[str, Any] = {"role": "assistant", "content": self.content}
d: dict[str, Any] = {"role": "assistant"}
if self.tool_calls:
d["tool_calls"] = self.tool_calls
d["content"] = self.content if self.content else None
else:
d["content"] = self.content or ""
return d
# role == "tool"
@@ -233,8 +236,8 @@ def extract_tool_call_history(messages: list[Message], max_entries: int = 30) ->
return args.get("query", "")
if name == "web_scrape":
return args.get("url", "")
if name in ("load_data", "save_data"):
return args.get("filename", "")
if name == "read_file":
return args.get("path", "")
return ""
for msg in messages:
@@ -250,8 +253,8 @@ def extract_tool_call_history(messages: list[Message], max_entries: int = 30) ->
summary = _summarize_input(name, args)
tool_calls_detail.setdefault(name, []).append(summary)
if name == "save_data" and args.get("filename"):
files_saved.append(args["filename"])
if name == "read_file" and args.get("path"):
files_saved.append(args["path"])
if name == "set_output" and args.get("key"):
outputs_set.append(args["key"])
@@ -324,7 +327,7 @@ def _try_extract_key(content: str, key: str) -> str | None:
3. Colon format: ``key: value``.
4. Equals format: ``key = value``.
"""
from framework.graph.node import find_json_object
from framework.orchestrator.node import find_json_object
# 1. Whole message is JSON
try:
@@ -453,6 +456,9 @@ class NodeConversation:
)
self._messages.append(msg)
self._next_seq += 1
# Invalidate stale API token count so estimate_tokens() uses
# the char-based heuristic which reflects the new message.
self._last_api_input_tokens = None
await self._persist(msg)
return msg
@@ -471,6 +477,7 @@ class NodeConversation:
)
self._messages.append(msg)
self._next_seq += 1
self._last_api_input_tokens = None
await self._persist(msg)
return msg
@@ -495,6 +502,7 @@ class NodeConversation:
)
self._messages.append(msg)
self._next_seq += 1
self._last_api_input_tokens = None
await self._persist(msg)
return msg
@@ -508,7 +516,48 @@ class NodeConversation:
can happen when a loop is cancelled mid-tool-execution.
"""
msgs = [m.to_llm_dict() for m in self._messages]
return self._repair_orphaned_tool_calls(msgs)
msgs = self._repair_orphaned_tool_calls(msgs)
msgs = self._sanitize_for_api(msgs)
return msgs
@staticmethod
def _sanitize_for_api(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Final pass: ensure message sequence is valid for strict APIs.
Rules:
1. No two consecutive messages with the same role (merge or drop)
2. Tool messages must have a tool_call_id
3. Assistant messages with tool_calls must have content=null, not ""
4. First message must not be 'tool' or 'assistant' (without prior context)
"""
cleaned: list[dict[str, Any]] = []
for m in msgs:
role = m.get("role")
# Fix assistant content when tool_calls present
if role == "assistant" and m.get("tool_calls"):
if m.get("content") == "":
m["content"] = None
# Drop tool messages without tool_call_id
if role == "tool" and not m.get("tool_call_id"):
continue
# Drop consecutive duplicate roles (merge user messages)
if cleaned and cleaned[-1].get("role") == role == "user":
prev_content = cleaned[-1].get("content", "")
curr_content = m.get("content", "")
if isinstance(prev_content, str) and isinstance(curr_content, str):
cleaned[-1]["content"] = f"{prev_content}\n{curr_content}"
continue
cleaned.append(m)
# Drop leading assistant/tool messages (no prior context)
while cleaned and cleaned[0].get("role") in ("assistant", "tool"):
cleaned.pop(0)
return cleaned
@staticmethod
def _repair_orphaned_tool_calls(
@@ -575,12 +624,15 @@ class NodeConversation:
Uses actual API input token count when available (set via
:meth:`update_token_count`), otherwise falls back to a
``total_chars / 4`` heuristic that includes both message content
AND tool_call argument sizes.
character-based heuristic that includes message content, tool_call
arguments, and image blocks. The heuristic applies a 4/3 safety
margin to avoid under-counting (inspired by Claude Code's compact
service).
"""
if self._last_api_input_tokens is not None:
return self._last_api_input_tokens
total_chars = 0
image_tokens = 0
for m in self._messages:
total_chars += len(m.content)
if m.tool_calls:
@@ -588,7 +640,11 @@ class NodeConversation:
func = tc.get("function", {})
total_chars += len(func.get("arguments", ""))
total_chars += len(func.get("name", ""))
return total_chars // 4
if m.image_content:
# Images/documents have a fixed token cost per block
image_tokens += len(m.image_content) * 2000
# Apply 4/3 safety margin to character-based estimate
return (total_chars * 4) // (3 * 4) + image_tokens
def update_token_count(self, actual_input_tokens: int) -> None:
"""Store actual API input token count for more accurate compaction.
@@ -721,7 +777,7 @@ class NodeConversation:
placeholder = (
f"[Pruned tool result: {orig_len} chars. "
f"Full data in '{spillover}'. "
f"Use load_data('{spillover}') to retrieve.]"
f"Use read_file('{spillover}') to retrieve.]"
)
else:
placeholder = f"[Pruned tool result: {orig_len} chars cleared from context.]"
@@ -877,6 +933,15 @@ class NodeConversation:
freeform_lines: list[str] = []
collapsed_msgs: list[Message] = []
# Collect all tool_use IDs present in old messages so we can detect
# orphaned tool results whose parent assistant message was already
# compacted away (API invariant protection).
old_tc_ids: set[str] = set()
for msg in old_messages:
if msg.tool_calls:
for tc in msg.tool_calls:
old_tc_ids.add(tc.get("id", ""))
if aggressive:
# Aggressive: only keep set_output tool pairs and error results.
# Everything else is collapsed into a tool-call history summary.
@@ -898,9 +963,17 @@ class NodeConversation:
else:
collapsible_tc_ids |= tc_ids
# Skill content and transition markers are always protected
for msg in old_messages:
if msg.role == "tool" and msg.is_skill_content and msg.tool_use_id:
protected_tc_ids.add(msg.tool_use_id)
# Second pass: classify all messages
for msg in old_messages:
if msg.role == "tool":
if msg.is_transition_marker:
# Transition markers are always kept (phase boundaries)
kept_structural.append(msg)
elif msg.role == "tool":
tc_id = msg.tool_use_id or ""
if tc_id in protected_tc_ids:
kept_structural.append(msg)
@@ -909,6 +982,12 @@ class NodeConversation:
kept_structural.append(msg)
# Protect the parent assistant message too
protected_tc_ids.add(tc_id)
elif msg.is_skill_content:
kept_structural.append(msg)
elif tc_id and tc_id not in old_tc_ids:
# Orphaned tool result — parent tool_use not in old msgs.
# Keep it to maintain API invariants.
kept_structural.append(msg)
else:
collapsed_msgs.append(msg)
elif msg.role == "assistant" and msg.tool_calls:
@@ -940,7 +1019,10 @@ class NodeConversation:
else:
# Standard mode: keep all tool call pairs as structural
for msg in old_messages:
if msg.role == "tool":
if msg.is_transition_marker:
# Transition markers are always kept (phase boundaries)
kept_structural.append(msg)
elif msg.role == "tool":
kept_structural.append(msg)
elif msg.role == "assistant" and msg.tool_calls:
compact_tcs = _compact_tool_calls(msg.tool_calls)
@@ -986,7 +1068,7 @@ class NodeConversation:
full_path = str((spill_path / conv_filename).resolve())
ref_parts.append(
f"[Previous conversation saved to '{full_path}'. "
f"Use load_data('{conv_filename}') to review if needed.]"
f"Use read_file('{conv_filename}') to review if needed.]"
)
elif not collapsed_msgs:
ref_parts.append("[Previous freeform messages compacted.]")
@@ -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
@@ -1,7 +1,8 @@
"""Conversation compaction pipeline.
Implements the multi-level compaction strategy:
1. Prune old tool results
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)
@@ -13,15 +14,16 @@ 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.graph.conversation import NodeConversation
from framework.graph.event_loop.event_publishing import publish_context_usage
from framework.graph.event_loop.types import LoopConfig, OutputAccumulator
from framework.graph.node import NodeContext
from framework.runtime.event_bus import EventBus
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.orchestrator.node import NodeContext
from framework.host.event_bus import EventBus
logger = logging.getLogger(__name__)
@@ -29,6 +31,121 @@ logger = logging.getLogger(__name__)
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", "[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 cleared: {orig_len} chars. "
f"Full data in '{spillover}'. "
f"Use read_file('{spillover}') to retrieve.]"
)
else:
placeholder = f"[Old tool result cleared: {orig_len} chars.]"
# 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."""
match = re.search(r"saved to '([^']+)'", content, re.IGNORECASE)
return match.group(1) if match else None
async def compact(
ctx: NodeContext,
@@ -43,11 +160,31 @@ async def compact(
"""Run the full compaction pipeline if conversation needs compaction.
Pipeline stages (in order, short-circuits when budget is restored):
1. Prune old tool results
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 auto-compacting after repeated failures
if _failure_counts.get(conv_id, 0) >= MAX_CONSECUTIVE_FAILURES:
logger.warning(
"Circuit breaker: skipping compaction after %d consecutive failures",
_failure_counts[conv_id],
)
return
# 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
@@ -55,6 +192,26 @@ async def compact(
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(
@@ -69,6 +226,7 @@ async def compact(
conversation.usage_ratio() * 100,
)
if not conversation.needs_compaction():
_record_success(conv_id, now)
await log_compaction(
ctx,
conversation,
@@ -87,6 +245,7 @@ async def compact(
phase_graduated=phase_grad,
)
if not conversation.needs_compaction():
_record_success(conv_id, now)
await log_compaction(
ctx,
conversation,
@@ -118,8 +277,10 @@ async def compact(
)
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,
@@ -140,6 +301,7 @@ async def compact(
keep_recent=1,
phase_graduated=phase_grad,
)
_record_success(conv_id, now)
await log_compaction(
ctx,
conversation,
@@ -149,9 +311,46 @@ async def compact(
)
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,
@@ -169,12 +368,16 @@ async def llm_compact(
in half and each half is summarised independently. Tool history is
appended once at the top-level call (``_depth == 0``).
"""
from framework.graph.conversation import extract_tool_call_history
from framework.graph.event_loop.tool_result_handler import is_context_too_large_error
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
@@ -297,8 +500,13 @@ def build_llm_compaction_prompt(
*,
max_context_tokens: int = 128_000,
) -> str:
"""Build prompt for LLM compaction targeting 50% of token budget."""
spec = ctx.node_spec
"""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}")
@@ -330,13 +538,30 @@ def build_llm_compaction_prompt(
f"CONVERSATION MESSAGES:\n{formatted_messages}\n\n"
"INSTRUCTIONS:\n"
f"Write a summary of approximately {target_chars} characters "
f"(~{target_tokens} tokens).\n"
"1. Preserve ALL user-stated rules, constraints, and preferences "
"verbatim.\n"
"2. Preserve key decisions made and results obtained.\n"
"3. Preserve in-progress work state so the agent can continue.\n"
"4. Be detailed enough that the agent can resume without "
"re-doing work.\n"
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"
"6. **User Messages** — Preserve ALL user-stated rules, constraints, "
"identity preferences, and account details verbatim.\n"
"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"
)
@@ -397,13 +622,13 @@ def write_compaction_debug_log(
log_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%S_%f")
node_label = ctx.node_id.replace("/", "_")
node_label = ctx.agent_id.replace("/", "_")
log_path = log_dir / f"{ts}_{node_label}.md"
lines: list[str] = [
f"# Compaction Debug — {ctx.node_id}",
f"# Compaction Debug — {ctx.agent_id}",
f"**Time:** {datetime.now(UTC).isoformat()}",
f"**Node:** {ctx.node_spec.name} (`{ctx.node_id}`)",
f"**Node:** {ctx.agent_spec.name} (`{ctx.agent_id}`)",
]
if ctx.stream_id:
lines.append(f"**Stream:** {ctx.stream_id}")
@@ -490,7 +715,7 @@ async def log_compaction(
if ctx.runtime_logger:
ctx.runtime_logger.log_step(
node_id=ctx.node_id,
node_id=ctx.agent_id,
node_type="event_loop",
step_index=-1,
llm_text=f"Context compacted ({level}): {before_pct}% \u2192 {after_pct}%",
@@ -499,7 +724,7 @@ async def log_compaction(
)
if event_bus:
from framework.runtime.event_bus import AgentEvent, EventType
from framework.host.event_bus import AgentEvent, EventType
event_data: dict[str, Any] = {
"level": level,
@@ -511,8 +736,8 @@ async def log_compaction(
await event_bus.publish(
AgentEvent(
type=EventType.CONTEXT_COMPACTED,
stream_id=ctx.stream_id or ctx.node_id,
node_id=ctx.node_id,
stream_id=ctx.stream_id or ctx.agent_id,
node_id=ctx.agent_id,
data=event_data,
)
)
@@ -543,7 +768,7 @@ def build_emergency_summary(
]
# 1. Node identity
spec = ctx.node_spec
spec = ctx.agent_spec
parts.append(f"NODE: {spec.name} (id={spec.id})")
if spec.description:
parts.append(f"PURPOSE: {spec.description}")
@@ -551,7 +776,7 @@ def build_emergency_summary(
# 2. Inputs the node received
input_lines = []
for key in spec.input_keys:
value = ctx.input_data.get(key) or ctx.buffer.read(key)
value = ctx.input_data.get(key)
if value is not None:
# Truncate long values but keep them recognisable
v_str = str(value)
@@ -598,13 +823,13 @@ def build_emergency_summary(
)
parts.append(
"CONVERSATION HISTORY (freeform messages saved during compaction — "
"use load_data('<filename>') to review earlier dialogue):\n" + conv_list
"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 load_data('<filename>') to read):\n" + file_list)
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. "
@@ -636,6 +861,6 @@ def _extract_tool_call_history(conversation: NodeConversation) -> str:
directly (vs. the module-level extract_tool_call_history in conversation.py
which works on raw message lists).
"""
from framework.graph.conversation import extract_tool_call_history
from framework.agent_loop.conversation import extract_tool_call_history
return extract_tool_call_history(list(conversation.messages))
@@ -14,9 +14,9 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from framework.graph.conversation import ConversationStore, NodeConversation
from framework.graph.event_loop.types import LoopConfig, OutputAccumulator, TriggerEvent
from framework.graph.node import NodeContext
from framework.agent_loop.conversation import ConversationStore, NodeConversation
from framework.agent_loop.internals.types import LoopConfig, OutputAccumulator, TriggerEvent
from framework.orchestrator.node import NodeContext
from framework.llm.capabilities import supports_image_tool_results
logger = logging.getLogger(__name__)
@@ -53,15 +53,31 @@ async def restore(
# 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)
phase_filter = None if _is_continuous else ctx.node_id
# 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.
@@ -124,7 +140,7 @@ async def write_cursor(
cursor.update(
{
"iteration": iteration,
"node_id": ctx.node_id,
"node_id": ctx.agent_id,
"outputs": accumulator.to_dict(),
}
)
@@ -153,7 +169,10 @@ async def drain_injection_queue(
) -> 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')
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()
@@ -242,11 +261,6 @@ async def check_pause(
# Check context-level pause flags (legacy/alternative methods)
pause_requested = ctx.input_data.get("pause_requested", False)
if not pause_requested:
try:
pause_requested = ctx.buffer.read("pause_requested") or False
except (PermissionError, KeyError):
pause_requested = False
if pause_requested:
completed = iteration
logger.info(f"⏸ Pausing after {completed} iteration(s) completed (context-level)")
@@ -9,10 +9,10 @@ from __future__ import annotations
import logging
import time
from framework.graph.conversation import NodeConversation
from framework.graph.event_loop.types import HookContext
from framework.graph.node import NodeContext
from framework.runtime.event_bus import EventBus
from framework.agent_loop.conversation import NodeConversation
from framework.agent_loop.internals.types import HookContext
from framework.orchestrator.node import NodeContext
from framework.host.event_bus import EventBus
logger = logging.getLogger(__name__)
@@ -45,14 +45,14 @@ async def generate_action_plan(
Runs as a fire-and-forget task so it never blocks the main loop.
"""
try:
system_prompt = ctx.node_spec.system_prompt or ""
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.node_spec.output_keys or []
output_keys = ctx.agent_spec.output_keys or []
prompt = (
f'You are about to work on a task as node "{node_id}".\n\n'
@@ -177,7 +177,7 @@ async def publish_context_usage(
if not event_bus:
return
from framework.runtime.event_bus import AgentEvent, EventType
from framework.host.event_bus import AgentEvent, EventType
estimated = conversation.estimate_tokens()
max_tokens = conversation._max_context_tokens
@@ -185,8 +185,8 @@ async def publish_context_usage(
await event_bus.publish(
AgentEvent(
type=EventType.CONTEXT_USAGE_UPDATED,
stream_id=ctx.stream_id or ctx.node_id,
node_id=ctx.node_id,
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),
@@ -319,9 +319,7 @@ async def publish_output_key_set(
execution_id: str = "",
) -> None:
if event_bus:
await event_bus.emit_output_key_set(
stream_id=stream_id, node_id=node_id, key=key, execution_id=execution_id
)
pass
async def run_hooks(
@@ -5,9 +5,9 @@ from __future__ import annotations
import logging
from collections.abc import Callable
from framework.graph.conversation import NodeConversation
from framework.graph.event_loop.types import JudgeProtocol, JudgeVerdict, OutputAccumulator
from framework.graph.node import NodeContext
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__)
@@ -79,7 +79,7 @@ async def judge_turn(
if mark_complete_flag:
return JudgeVerdict(action="ACCEPT")
if ctx.node_spec.skip_judge:
if ctx.agent_spec.skip_judge:
return JudgeVerdict(action="RETRY") # feedback=None → not logged
# --- Level 1: custom judge -----------------------------------------
@@ -92,9 +92,9 @@ async def judge_turn(
"accumulator": accumulator,
"iteration": iteration,
"conversation_summary": conversation.export_summary(),
"output_keys": ctx.node_spec.output_keys,
"output_keys": ctx.agent_spec.output_keys,
"missing_keys": get_missing_output_keys_fn(
accumulator, ctx.node_spec.output_keys, ctx.node_spec.nullable_output_keys
accumulator, ctx.agent_spec.output_keys, ctx.agent_spec.nullable_output_keys
),
}
verdict = await judge.evaluate(context)
@@ -110,7 +110,7 @@ async def judge_turn(
return JudgeVerdict(action="RETRY") # feedback=None → not logged
missing = get_missing_output_keys_fn(
accumulator, ctx.node_spec.output_keys, ctx.node_spec.nullable_output_keys
accumulator, ctx.agent_spec.output_keys, ctx.agent_spec.nullable_output_keys
)
if missing:
@@ -124,8 +124,8 @@ async def judge_turn(
# All output keys present — run safety checks before accepting.
output_keys = ctx.node_spec.output_keys or []
nullable_keys = set(ctx.node_spec.nullable_output_keys or [])
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)
@@ -139,30 +139,16 @@ async def judge_turn(
),
)
# Queen with no output keys → continuous interaction node.
# Inject tool-use pressure instead of auto-accepting.
if not output_keys and ctx.supports_direct_user_io:
return JudgeVerdict(
action="RETRY",
feedback=(
"STOP describing what you will do. "
"You have FULL access to all tools — file creation, "
"shell commands, MCP tools — and you CAN call them "
"directly in your response. Respond ONLY with tool "
"calls, no prose. Execute the task now."
),
)
# Level 2b: conversation-aware quality check (if success_criteria set)
if ctx.node_spec.success_criteria and ctx.llm:
from framework.graph.conversation_judge import evaluate_phase_completion
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.node_spec.name,
phase_description=ctx.node_spec.description,
success_criteria=ctx.node_spec.success_criteria,
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,
)
@@ -15,6 +15,82 @@ 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
def build_ask_user_tool() -> Tool:
"""Build the synthetic ask_user tool for explicit user-input requests.
@@ -28,7 +104,20 @@ def build_ask_user_tool() -> Tool:
"You MUST call this tool whenever you need the user's response. "
"Always call it after greeting the user, asking a question, or "
"requesting approval. Do NOT call it for status updates or "
"summaries that don't require a response. "
"summaries that don't require a response.\n\n"
"STRUCTURE RULES (CRITICAL):\n"
"- The 'question' field is PLAIN TEXT shown to the user. Do NOT "
"include XML tags, pseudo-tags like </question>, or option lists "
"in the question string. The UI does not parse them — they "
"render as raw text and look broken.\n"
"- The 'options' parameter is the ONLY way to render buttons. "
"If you want buttons, put them in the 'options' array, not in "
"the question string. Do NOT write 'OPTIONS: [...]', "
"'_options: [...]', or any inline list inside 'question'.\n"
"- The question text must read as a single clean prompt with "
"no markup. Example: 'What would you like to do?' — not "
"'What would you like to do?</question>'.\n\n"
"USAGE:\n"
"Always include 2-3 predefined options. The UI automatically "
"appends an 'Other' free-text input after your options, so NEVER "
"include catch-all options like 'Custom idea', 'Something else', "
@@ -39,11 +128,14 @@ def build_ask_user_tool() -> Tool:
"free-text input. "
"The ONLY exception: omit options when the question demands a "
"free-form answer the user must type out (e.g. 'Describe your "
"agent idea', 'Paste the error message'). "
"agent idea', 'Paste the error message').\n\n"
"CORRECT EXAMPLE:\n"
'{"question": "What would you like to do?", "options": '
'["Build a new agent", "Modify existing agent", "Run tests"]} '
"Free-form example: "
'{"question": "Describe the agent you want to build."}'
'["Build a new agent", "Modify existing agent", "Run tests"]}\n\n'
"FREE-FORM EXAMPLE:\n"
'{"question": "Describe the agent you want to build."}\n\n'
"WRONG (do NOT do this — buttons will not render):\n"
'{"question": "What now?</question>\\n_OPTIONS: [\\"A\\", \\"B\\"]"}'
),
parameters={
"type": "object",
@@ -205,117 +297,93 @@ def build_escalate_tool() -> Tool:
)
def build_delegate_tool(sub_agents: list[str], node_registry: dict[str, Any]) -> Tool | None:
"""Build the synthetic delegate_to_sub_agent tool for subagent invocation.
Args:
sub_agents: List of node IDs that can be invoked as subagents.
node_registry: Map of node_id -> NodeSpec for looking up subagent descriptions.
Returns:
Tool definition if sub_agents is non-empty, None otherwise.
"""
if not sub_agents:
return None
agent_descriptions = []
for agent_id in sub_agents:
spec = node_registry.get(agent_id)
if spec:
desc = getattr(spec, "description", "(no description)")
agent_descriptions.append(f"- {agent_id}: {desc}")
else:
agent_descriptions.append(f"- {agent_id}: (not found in registry)")
return Tool(
name="delegate_to_sub_agent",
description=(
"Delegate a task to a specialized sub-agent. The sub-agent runs "
"autonomously with read-only access to current memory and returns "
"its result. Use this to parallelize work or leverage specialized capabilities.\n\n"
"Available sub-agents:\n" + "\n".join(agent_descriptions)
),
parameters={
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": f"The sub-agent to invoke. Must be one of: {sub_agents}",
"enum": sub_agents,
},
"task": {
"type": "string",
"description": (
"The task description for the sub-agent to execute. "
"Be specific about what you want the sub-agent to do and "
"what information to return."
),
},
},
"required": ["agent_id", "task"],
},
)
def build_report_to_parent_tool() -> Tool:
"""Build the synthetic report_to_parent tool for sub-agent progress reports.
"""Build the synthetic ``report_to_parent`` tool.
Sub-agents call this to send one-way progress updates, partial findings,
or status reports to the parent node (and external observers via event bus)
without blocking execution.
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.
When ``wait_for_response`` is True, the sub-agent blocks until the parent
relays the user's response — used for escalation (e.g. login pages, CAPTCHAs).
When ``mark_complete`` is True, the sub-agent terminates immediately after
sending the report no need to call set_output for each output key.
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 report to the parent agent. By default this is fire-and-forget: "
"the parent receives the report but does not respond. "
"Set wait_for_response=true to BLOCK until the user replies — use this "
"when you need human intervention (e.g. login pages, CAPTCHAs, "
"authentication walls). The user's response is returned as the tool result. "
"Set mark_complete=true to finish your task and terminate immediately "
"after sending the report — use this when your findings are in the "
"message/data fields and you don't need to call set_output."
"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": {
"message": {
"status": {
"type": "string",
"description": "A human-readable status or progress message.",
"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 data to include with the report.",
},
"wait_for_response": {
"type": "boolean",
"description": (
"If true, block execution until the user responds. "
"Use for escalation scenarios requiring human intervention."
"Optional structured payload (rows fetched, IDs "
"processed, files written, etc.) that the "
"overseer can merge into its final summary."
),
"default": False,
},
"mark_complete": {
"type": "boolean",
"description": (
"If true, terminate the sub-agent immediately after sending "
"this report. The report message and data are delivered to the "
"parent as the final result. No set_output calls are needed."
),
"default": False,
},
},
"required": ["message"],
"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}). "
f"This worker will terminate now."
),
)
def handle_set_output(
tool_input: dict[str, Any],
output_keys: list[str] | None,
@@ -222,7 +222,7 @@ def truncate_tool_result(
- Small results ( limit): full content kept + file annotation
- Large results (> limit): preview + file reference
- Errors: pass through unchanged
- load_data results: truncate with pagination hint (no re-spill)
- read_file results: truncate with pagination hint (no re-spill)
"""
limit = max_tool_result_chars
@@ -230,12 +230,12 @@ def truncate_tool_result(
if result.is_error:
return result
# load_data reads FROM spilled files — never re-spill (circular).
# 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 == "load_data":
if tool_name == "read_file":
if limit <= 0 or len(result.content) <= limit:
return result # Small load_data result — pass through as-is
# Large load_data result — truncate with smart preview
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 = ""
@@ -284,7 +284,7 @@ def truncate_tool_result(
spill_path.mkdir(parents=True, exist_ok=True)
filename = next_spill_filename_fn(tool_name)
# Pretty-print JSON content so load_data's line-based
# 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
@@ -294,7 +294,10 @@ def truncate_tool_result(
except (json.JSONDecodeError, TypeError, ValueError):
pass # Not JSON — write as-is
(spill_path / filename).write_text(write_content, encoding="utf-8")
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
@@ -316,14 +319,14 @@ def truncate_tool_result(
# Assemble header with structural info + warning
header = (
f"[Result from {tool_name}: {len(result.content):,} chars — "
f"too large for context, saved to '{filename}'.]\n"
f"too large for context, saved to '{abs_path}'.]\n"
)
if metadata_str:
header += f"\nData structure:\n{metadata_str}"
header += (
f"\n\nWARNING: The preview below is INCOMPLETE. "
f"Do NOT draw conclusions or counts from it. "
f"Use load_data(filename='{filename}') to read the "
f"Use read_file(path='{abs_path}') to read the "
f"full data before analysis."
)
@@ -332,11 +335,11 @@ def truncate_tool_result(
"Tool result spilled to file: %s (%d chars → %s)",
tool_name,
len(result.content),
filename,
abs_path,
)
else:
# Small result: keep full content + annotation
content = f"{result.content}\n\n[Saved to '{filename}']"
# Small result: keep full content + annotation with absolute path
content = f"{result.content}\n\n[Saved to '{abs_path}']"
logger.info(
"Tool result saved to file: %s (%d chars → %s)",
tool_name,
@@ -420,7 +423,7 @@ async def execute_tool(
)
skill_dirs = skill_dirs or []
skill_read_tools = {"view_file", "load_data", "read_file"}
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:
@@ -9,10 +9,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, Protocol, runtime_checkable
from framework.graph.conversation import (
from framework.agent_loop.conversation import (
ConversationStore,
get_run_cursor,
update_run_cursor,
)
logger = logging.getLogger(__name__)
@@ -70,7 +68,7 @@ class LoopConfig:
max_output_value_chars: int = 2_000
# Stream retry.
max_stream_retries: int = 3
max_stream_retries: int = 5
stream_retry_backoff_base: float = 2.0
stream_retry_max_delay: float = 60.0
@@ -79,13 +77,23 @@ class LoopConfig:
# 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
# Subagent delegation timeout.
subagent_timeout_seconds: float = 600.0
# 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
@@ -151,8 +159,9 @@ class OutputAccumulator:
if isinstance(value, (dict, list))
else str(value)
)
(spill_path / filename).write_text(write_content, encoding="utf-8")
file_size = (spill_path / filename).stat().st_size
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,
@@ -160,9 +169,11 @@ class OutputAccumulator:
filename,
file_size,
)
# Use absolute path so parent agents can find files from subagents
abs_path = str(file_path.resolve())
return (
f"[Saved to '{filename}' ({file_size:,} bytes). "
f"Use load_data(filename='{filename}') "
f"[Saved to '{abs_path}' ({file_size:,} bytes). "
f"Use read_file(path='{abs_path}') "
f"to access full data.]"
)
+93
View File
@@ -0,0 +1,93 @@
"""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:
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 ""
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=ctx.skills_catalog_prompt or "",
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)
+257
View File
@@ -0,0 +1,257 @@
"""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
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
+9 -1
View File
@@ -8,6 +8,14 @@ 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.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 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.host.agent_host import AgentHost
from framework.host.execution_manager import EntryPointSpec
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__)
@@ -233,7 +233,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
@@ -325,7 +325,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 +368,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,
@@ -497,7 +497,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
@@ -613,7 +613,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,6 +1,6 @@
"""Node definitions for Credential Tester agent."""
from framework.graph import NodeSpec
from framework.orchestrator import NodeSpec
def build_tester_node(
+148 -78
View File
@@ -7,6 +7,32 @@ from dataclasses import dataclass, field
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 +47,15 @@ class AgentEntry:
tool_count: int = 0
tags: list[str] = field(default_factory=list)
last_active: 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 +79,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 +143,118 @@ 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."""
tool_count, tags = 0, []
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
colony_queen_name = ""
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", "")
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)
all_tools: set[str] = set()
for w in worker_entries:
pass # tool_count already per-worker
tool_count = max((w.tool_count for w in worker_entries), default=0)
entries.append(
AgentEntry(
@@ -199,11 +266,14 @@ 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),
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",
+14 -24
View File
@@ -1,15 +1,13 @@
"""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",
@@ -20,19 +18,11 @@ queen_goal = Goal(
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,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": "Hive tools MCP server (csv, pdf, web_search, web_scrape, email, integrations)"
}
}
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 ""
+37 -355
View File
@@ -1,25 +1,23 @@
"""Shared memory helpers for queen/worker recall and reflection.
"""Queen global memory helpers.
Each memory is an individual ``.md`` file in ``~/.hive/queen/memories/``
with optional YAML frontmatter (name, type, description). Frontmatter
is a convention enforced by prompt instructions parsing is lenient and
malformed files degrade gracefully (appear in scans with ``None`` metadata).
Memory hierarchy::
Cursor-based incremental processing tracks which conversation messages
have already been processed by the reflection agent.
~/.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 json
import logging
import re
import shutil
import time
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
@@ -27,54 +25,35 @@ logger = logging.getLogger(__name__)
# Constants
# ---------------------------------------------------------------------------
MEMORY_TYPES: tuple[str, ...] = ("goal", "environment", "technique", "reference", "diary")
GLOBAL_MEMORY_CATEGORIES: tuple[str, ...] = ("profile", "preference", "environment", "feedback")
_HIVE_QUEEN_DIR = Path.home() / ".hive" / "queen"
# Legacy shared v2 root. Colony memory now lives under queen sessions.
MEMORY_DIR: Path = _HIVE_QUEEN_DIR / "memories"
from framework.config import MEMORIES_DIR
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
_MIGRATION_MARKER = ".migrated-from-shared-memory"
_GLOBAL_MEMORY_CODE_PATTERN = re.compile(
r"(/Users/|~/.hive|\.py\b|\.ts\b|\.tsx\b|\.js\b|"
r"\b(graph|node|runtime|session|execution|worker|queen|subagent|checkpoint|flowchart)\b)",
re.IGNORECASE,
)
# Frontmatter example provided to the reflection agent via prompt.
MEMORY_FRONTMATTER_EXAMPLE: list[str] = [
"```markdown",
"---",
"name: {{memory name}}",
(
"description: {{one-line description — used to decide "
"relevance in future conversations, so be specific}}"
),
f"type: {{{{{', '.join(MEMORY_TYPES)}}}}}",
"---",
"",
(
"{{memory content — for feedback/project types, "
"structure as: rule/fact, then **Why:** "
"and **How to apply:** lines}}"
),
"```",
]
def colony_memory_dir(colony_id: str) -> Path:
"""Return the colony memory directory for a queen session."""
return _HIVE_QUEEN_DIR / "session" / colony_id / "memory" / "colony"
def global_memory_dir() -> Path:
"""Return the queen-global memory directory."""
return _HIVE_QUEEN_DIR / "global_memory"
"""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
# ---------------------------------------------------------------------------
@@ -108,15 +87,6 @@ def parse_frontmatter(text: str) -> dict[str, str]:
return result
def parse_memory_type(raw: str | None) -> str | None:
"""Validate *raw* against supported memory categories."""
if raw is None:
return None
normalized = raw.strip().lower()
allowed = set(MEMORY_TYPES) | set(GLOBAL_MEMORY_CATEGORIES)
return normalized if normalized in allowed else None
def parse_global_memory_category(raw: str | None) -> str | None:
"""Validate *raw* against ``GLOBAL_MEMORY_CATEGORIES``."""
if raw is None:
@@ -165,7 +135,7 @@ class MemoryFile:
filename=path.name,
path=path,
name=fm.get("name"),
type=parse_memory_type(fm.get("type")),
type=parse_global_memory_category(fm.get("type")),
description=fm.get("description"),
header_lines=lines,
mtime=mtime,
@@ -183,7 +153,7 @@ def scan_memory_files(memory_dir: Path | None = None) -> list[MemoryFile]:
Files are sorted by modification time (newest first). Dotfiles and
subdirectories are ignored.
"""
d = memory_dir or MEMORY_DIR
d = memory_dir or global_memory_dir()
if not d.is_dir():
return []
@@ -236,318 +206,30 @@ def build_memory_document(
)
def diary_filename(d: date | None = None) -> str:
"""Return the diary memory filename for date *d* (default: today)."""
d = d or date.today()
return f"MEMORY-{d.strftime('%Y-%m-%d')}.md"
def build_diary_document(*, date_str: str, body: str) -> str:
"""Build a diary memory file with frontmatter."""
return build_memory_document(
name=f"diary-{date_str}",
description=f"Daily session narrative for {date_str}",
mem_type="diary",
body=body,
)
def validate_global_memory_payload(
*,
category: str,
description: str,
content: str,
) -> str:
"""Validate a queen-global memory save request."""
parsed = parse_global_memory_category(category)
if parsed is None:
raise ValueError(
"Invalid global memory category. Use one of: "
+ ", ".join(GLOBAL_MEMORY_CATEGORIES)
)
if not description.strip():
raise ValueError("Global memory description cannot be empty.")
if not content.strip():
raise ValueError("Global memory content cannot be empty.")
probe = f"{description}\n{content}"
if _GLOBAL_MEMORY_CODE_PATTERN.search(probe):
raise ValueError(
"Global memory is only for durable user profile, preferences, "
"environment, or feedback — not task/code/runtime details."
)
return parsed
def save_global_memory(
*,
category: str,
description: str,
content: str,
name: str | None = None,
memory_dir: Path | None = None,
) -> tuple[str, Path]:
"""Persist one queen-global memory entry."""
parsed = validate_global_memory_payload(
category=category,
description=description,
content=content,
)
target_dir = memory_dir or global_memory_dir()
target_dir.mkdir(parents=True, exist_ok=True)
memory_name = (name or description).strip()
filename = allocate_memory_filename(target_dir, memory_name)
doc = build_memory_document(
name=memory_name,
description=description,
mem_type=parsed,
body=content,
)
if len(doc.encode("utf-8")) > MAX_FILE_SIZE_BYTES:
raise ValueError(
f"Global memory entry exceeds the {MAX_FILE_SIZE_BYTES} byte limit."
)
path = target_dir / filename
path.write_text(doc, encoding="utf-8")
return filename, path
# ---------------------------------------------------------------------------
# Manifest formatting
# ---------------------------------------------------------------------------
def _age_label(mtime: float) -> str:
"""Human-readable age string from an mtime."""
age_days = memory_age_days(mtime)
if age_days <= 0:
return "today"
if age_days == 1:
return "1 day ago"
return f"{age_days} days ago"
def format_memory_manifest(files: list[MemoryFile]) -> str:
"""One-line-per-file text manifest for the recall selector / reflection agent.
"""One-line-per-file text manifest.
Format: ``[type] filename (age): description``
Format: ``[type] filename: description``
"""
lines: list[str] = []
for mf in files:
t = mf.type or "unknown"
desc = mf.description or "(no description)"
age = _age_label(mf.mtime)
lines.append(f"[{t}] {mf.filename} ({age}): {desc}")
lines.append(f"[{t}] {mf.filename}: {desc}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Freshness / staleness
# ---------------------------------------------------------------------------
_SECONDS_PER_DAY = 86_400
def memory_age_days(mtime: float) -> int:
"""Return the age of a memory file in whole days."""
if mtime <= 0:
return 0
return int((time.time() - mtime) / _SECONDS_PER_DAY)
def memory_freshness_text(mtime: float) -> str:
"""Return a staleness warning for injection, or empty string if fresh."""
d = memory_age_days(mtime)
if d <= 1:
return ""
return (
f"This memory is {d} days old. "
"Memories are point-in-time observations, not live state — "
"claims about code behavior or file:line citations may be outdated. "
"Verify against current code before asserting as fact."
)
# ---------------------------------------------------------------------------
# Cursor-based incremental processing
# Initialisation
# ---------------------------------------------------------------------------
async def read_conversation_parts(session_dir: Path) -> list[dict[str, Any]]:
"""Read all conversation parts for a session using FileConversationStore.
Returns a list of raw message dicts in sequence order.
"""
from framework.storage.conversation_store import FileConversationStore
store = FileConversationStore(session_dir / "conversations")
return await store.read_parts()
# ---------------------------------------------------------------------------
# Initialisation and legacy migration
# ---------------------------------------------------------------------------
def init_memory_dir(
memory_dir: Path | None = None,
*,
migrate_legacy: bool = False,
) -> None:
"""Create the memory directory if missing.
When ``migrate_legacy`` is true, migrate both v1 memory files and the
previous shared v2 queen memory store into this directory.
"""
d = memory_dir or MEMORY_DIR
first_run = not d.exists()
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)
if migrate_legacy:
migrate_legacy_memories(d)
migrate_shared_v2_memories(d)
elif first_run and d == MEMORY_DIR:
migrate_legacy_memories(d)
def migrate_legacy_memories(memory_dir: Path | None = None) -> None:
"""Convert old MEMORY.md + MEMORY-YYYY-MM-DD.md files to individual memory files.
Originals are moved to ``{memory_dir}/.legacy/``.
"""
d = memory_dir or MEMORY_DIR
queen_dir = _HIVE_QUEEN_DIR
legacy_archive = d / ".legacy"
migrated_any = False
# --- Semantic memory (MEMORY.md) ---
semantic = queen_dir / "MEMORY.md"
if semantic.exists():
content = semantic.read_text(encoding="utf-8").strip()
# Skip the blank seed template.
if content and not content.startswith("# My Understanding of the User\n\n*No sessions"):
_write_migration_file(
d,
filename="legacy-semantic-memory.md",
name="legacy-semantic-memory",
mem_type="reference",
description="Migrated semantic memory from previous memory system",
body=content,
)
migrated_any = True
# Archive original.
legacy_archive.mkdir(parents=True, exist_ok=True)
semantic.rename(legacy_archive / "MEMORY.md")
# --- Episodic memories (MEMORY-YYYY-MM-DD.md) ---
old_memories_dir = queen_dir / "memories"
if old_memories_dir.is_dir():
for ep_file in sorted(old_memories_dir.glob("MEMORY-*.md")):
content = ep_file.read_text(encoding="utf-8").strip()
if not content:
continue
date_part = ep_file.stem.replace("MEMORY-", "")
slug = f"legacy-diary-{date_part}.md"
_write_migration_file(
d,
filename=slug,
name=f"legacy-diary-{date_part}",
mem_type="diary",
description=f"Migrated diary entry from {date_part}",
body=content,
)
migrated_any = True
# Archive original.
legacy_archive.mkdir(parents=True, exist_ok=True)
ep_file.rename(legacy_archive / ep_file.name)
if migrated_any:
logger.info("queen_memory_v2: migrated legacy memory files to %s", d)
def migrate_shared_v2_memories(
memory_dir: Path | None = None,
*,
source_dir: Path | None = None,
) -> None:
"""Move shared queen v2 memory files into a colony directory once."""
d = memory_dir or MEMORY_DIR
d.mkdir(parents=True, exist_ok=True)
src = source_dir or MEMORY_DIR
if d.resolve() == src.resolve():
return
marker = d / _MIGRATION_MARKER
if marker.exists():
return
if not src.is_dir():
return
md_files = sorted(
f for f in src.glob("*.md")
if f.is_file() and not f.name.startswith(".")
)
if not md_files:
marker.write_text("no shared memories found\n", encoding="utf-8")
return
archive = src / ".legacy_colony_migration"
archive.mkdir(parents=True, exist_ok=True)
migrated_any = False
for src_file in md_files:
target = d / src_file.name
if not target.exists():
try:
shutil.copy2(src_file, target)
migrated_any = True
except OSError:
logger.debug("shared memory migration copy failed for %s", src_file, exc_info=True)
continue
archived = archive / src_file.name
counter = 2
while archived.exists():
archived = archive / f"{src_file.stem}-{counter}{src_file.suffix}"
counter += 1
try:
src_file.rename(archived)
except OSError:
logger.debug("shared memory migration archive failed for %s", src_file, exc_info=True)
if migrated_any:
logger.info("queen_memory_v2: migrated shared queen memories to %s", d)
marker.write_text(
f"migrated_at={int(time.time())}\nsource={src}\n",
encoding="utf-8",
)
def _write_migration_file(
memory_dir: Path,
filename: str,
name: str,
mem_type: str,
description: str,
body: str,
) -> None:
"""Write a single migrated memory file with frontmatter."""
# Truncate body to respect file size limit (leave room for frontmatter).
header = (
f"---\n"
f"name: {name}\n"
f"description: {description}\n"
f"type: {mem_type}\n"
f"---\n\n"
)
max_body = MAX_FILE_SIZE_BYTES - len(header.encode("utf-8"))
if len(body.encode("utf-8")) > max_body:
# Rough truncation — cut at character level then trim to last newline.
body = body[: max_body - 20]
nl = body.rfind("\n")
if nl > 0:
body = body[:nl]
body += "\n\n...(truncated during migration)"
path = memory_dir / filename
path.write_text(header + body + "\n", encoding="utf-8")
File diff suppressed because it is too large Load Diff
+36 -131
View File
@@ -1,11 +1,11 @@
"""Recall selector — pre-turn memory selection for queen and worker memory.
"""Recall selector — pre-turn global memory selection for the queen.
Before each conversation turn the system:
1. Scans the memory directory for ``.md`` files (cap: 200).
1. Scans the global memory directory for ``.md`` files (cap: 200).
2. Reads headers (frontmatter + first 30 lines).
3. Uses a single LLM call with structured JSON output to pick the ~5
most relevant memories.
4. Injects them into context with staleness warnings for older ones.
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
@@ -20,9 +20,8 @@ from pathlib import Path
from typing import Any
from framework.agents.queen.queen_memory_v2 import (
MEMORY_DIR,
format_memory_manifest,
memory_freshness_text,
global_memory_dir,
scan_memory_files,
)
@@ -32,29 +31,6 @@ logger = logging.getLogger(__name__)
# Structured output schema
# ---------------------------------------------------------------------------
RECALL_SCHEMA: dict[str, Any] = {
"type": "json_schema",
"json_schema": {
"name": "memory_selection",
"strict": True,
"schema": {
"type": "object",
"properties": {
"selected_memories": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["selected_memories"],
"additionalProperties": False,
},
},
}
# ---------------------------------------------------------------------------
# System prompt
# ---------------------------------------------------------------------------
SELECT_MEMORIES_SYSTEM_PROMPT = """\
You are selecting memories that will be useful to the Queen agent as it \
processes a user's query.
@@ -72,9 +48,6 @@ name and description.
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.
- If a list of recently-used tools is provided, do not select memories \
that are usage reference or API documentation for those tools (the Queen \
is already exercising them). Still select warnings or gotchas about them.
"""
# ---------------------------------------------------------------------------
@@ -86,7 +59,6 @@ async def select_memories(
query: str,
llm: Any,
memory_dir: Path | None = None,
active_tools: list[str] | None = None,
*,
max_results: int = 5,
) -> list[str]:
@@ -94,51 +66,60 @@ async def select_memories(
Returns a list of filenames. Best-effort: on any error returns ``[]``.
"""
mem_dir = memory_dir or MEMORY_DIR
mem_dir = memory_dir or 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 memory files for query: %.80s", len(files), query)
logger.debug("recall: selecting from %d memories for query: %.100s", len(files), query)
manifest = format_memory_manifest(files)
user_msg_parts = [f"## User query\n\n{query}\n\n## Available memories\n\n{manifest}"]
if active_tools:
user_msg_parts.append(f"\n\n## Recently-used tools\n\n{', '.join(active_tools)}")
user_msg = "".join(user_msg_parts)
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=512,
response_format=RECALL_SCHEMA,
max_tokens=1024,
response_format={"type": "json_object"},
)
data = json.loads(resp.content)
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", [])
# Validate: only return filenames that actually exist.
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:
logger.debug("recall: memory selection failed, returning []", exc_info=True)
except Exception as exc:
logger.warning("recall: memory selection failed (%s), returning []", exc)
return []
def format_recall_injection(
filenames: list[str],
memory_dir: Path | None = None,
*,
heading: str = "Selected Memories",
) -> str:
"""Read selected memory files and format for system prompt injection.
Prepends a staleness warning for memories older than 1 day.
"""
mem_dir = memory_dir or MEMORY_DIR
"""Read selected memory files and format for system prompt injection."""
mem_dir = memory_dir or global_memory_dir()
if not filenames:
return ""
@@ -151,86 +132,10 @@ def format_recall_injection(
content = path.read_text(encoding="utf-8").strip()
except OSError:
continue
try:
mtime = path.stat().st_mtime
except OSError:
mtime = 0.0
freshness = memory_freshness_text(mtime)
header = f"### {fname}"
if freshness:
header += f"\n\n> {freshness}"
blocks.append(f"{header}\n\n{content}")
blocks.append(f"### {fname}\n\n{content}")
if not blocks:
return ""
body = "\n\n---\n\n".join(blocks)
logger.debug("recall: injecting %d memory blocks into context", len(blocks))
return f"--- {heading} ---\n\n{body}\n\n--- End {heading} ---"
# ---------------------------------------------------------------------------
# Cache update (called after each queen turn)
# ---------------------------------------------------------------------------
async def update_recall_cache(
session_dir: Path,
llm: Any,
phase_state: Any | None = None,
memory_dir: Path | None = None,
*,
cache_setter: Any = None,
heading: str = "Selected Memories",
active_tools: list[str] | None = None,
) -> None:
"""Update the recall cache on *phase_state* for the next turn.
Reads the latest user message from conversation parts to use as the
query for memory selection.
"""
mem_dir = memory_dir or MEMORY_DIR
# Extract latest user message as the query.
query = _extract_latest_user_query(session_dir)
if not query:
logger.debug("recall: no user query found, skipping cache update")
return
logger.debug("recall: updating cache for query: %.80s", query)
try:
selected = await select_memories(
query,
llm,
mem_dir,
active_tools=active_tools,
)
injection = format_recall_injection(selected, mem_dir, heading=heading)
if cache_setter is not None:
cache_setter(injection)
elif phase_state is not None:
phase_state._cached_recall_block = injection
except Exception:
logger.debug("recall: cache update failed", exc_info=True)
def _extract_latest_user_query(session_dir: Path) -> str:
"""Read the most recent user message from conversation parts."""
parts_dir = session_dir / "conversations" / "parts"
if not parts_dir.is_dir():
return ""
part_files = sorted(parts_dir.glob("*.json"), reverse=True)
for f in part_files[:20]: # Look back at most 20 messages.
try:
data = json.loads(f.read_text(encoding="utf-8"))
if data.get("role") == "user":
content = str(data.get("content", "")).strip()
if content:
# Truncate very long queries.
return content[:1000] if len(content) > 1000 else content
except (json.JSONDecodeError, OSError):
continue
return ""
return f"--- Global Memories ---\n\n{body}\n\n--- End Global Memories ---"
@@ -25,10 +25,7 @@
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. Route worker review/approval through queen escalation instead of direct worker HITL.
@@ -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,306 +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 | Deprecated compatibility field. Queen interactivity is implicit; workers should escalate instead |
| 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 the shared buffer 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 (queen-mediated)
```
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. Mid-execution review/approval should happen through queen
escalation rather than direct worker HITL.
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 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 the shared buffer
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,53 @@
# 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` -- launch/navigate
- `browser_click`, `browser_fill`, `browser_type` -- interact
- `browser_snapshot` -- read page content (preferred over screenshot)
- `browser_screenshot` -- visual capture
- `browser_scroll`, `browser_wait` -- navigation helpers
- `browser_evaluate` -- run JavaScript
GCU nodes are **subagents** — invoked via `delegate_to_sub_agent()`, not connected via edges.
## System Prompt Tips for Browser Nodes
- 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", ...)`
## GCU Node Definition Template
```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].
## 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)
## Output format
set_output("result", JSON) with:
- [field]: [type and description]
""",
)
```
1. Use browser_snapshot() to read page content (NOT browser_get_text)
2. Use browser_wait(seconds=2-3) after navigation for page load
3. If you hit an auth wall, call set_output with an error and move on
4. 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, paginate through results..."
}
```
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.
## 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)
+203 -289
View File
@@ -1,21 +1,20 @@
"""Reflect agent — background memory extraction for queen and worker memory.
"""Reflection agent — background global memory extraction for the queen.
A lightweight side agent that runs after each queen LLM turn. It
inspects recent conversation messages (cursor-based incremental
processing) and extracts learnings into individual memory files.
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 ``~/.hive/memories/global/``.
Two reflection types:
- **Short reflection**: every queen turn. Distills learnings. Nudged
toward a 2-turn pattern (batch reads batch writes).
- **Long reflection**: every 5 short reflections, on CONTEXT_COMPACTED,
and at session end. Organises, deduplicates, trims holistically.
- **Short reflection**: after conversational queen turns. Distills
learnings about the user (profile, preferences, environment, feedback).
- **Long reflection**: every 5 short reflections and on CONTEXT_COMPACTED.
Organises, deduplicates, trims the global memory directory.
The agent has restricted tool access: it can only read/write/delete
memory files in ``~/.hive/queen/memories/`` and list them.
Concurrency: an ``asyncio.Lock`` prevents overlapping runs. If a trigger
fires while a reflection is already active the event is skipped.
Concurrency: an ``asyncio.Lock`` prevents overlapping runs. If a
trigger fires while a reflection is already active the event is skipped
(cursor hasn't advanced, so messages will be reconsidered next time).
All reflections are fire-and-forget (spawned via ``asyncio.create_task``)
so they never block the queen's event loop.
"""
from __future__ import annotations
@@ -23,22 +22,18 @@ from __future__ import annotations
import asyncio
import json
import logging
import re
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,
MEMORY_DIR,
MEMORY_FRONTMATTER_EXAMPLE,
MEMORY_TYPES,
build_diary_document,
diary_filename,
format_memory_manifest,
read_conversation_parts,
global_memory_dir,
parse_frontmatter,
scan_memory_files,
)
from framework.llm.provider import LLMResponse, Tool
@@ -53,7 +48,7 @@ _REFLECTION_TOOLS: list[Tool] = [
Tool(
name="list_memory_files",
description=(
"List all memory files with their type, name, age, and description. "
"List all memory files with their type, name, and description. "
"Returns a text manifest — one line per file."
),
parameters={
@@ -161,6 +156,14 @@ def _execute_tool(name: str, args: dict[str, Any], memory_dir: Path) -> str:
content = args.get("content", "")
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}'. "
f"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."
@@ -206,19 +209,17 @@ async def _reflection_loop(
user_msg: str,
memory_dir: Path,
max_turns: int = _MAX_TURNS,
) -> bool:
) -> tuple[bool, list[str], str]:
"""Run a mini tool-use loop: LLM → tool calls → repeat.
Hard cap of *max_turns* iterations. Prompt nudges the LLM toward a
2-turn pattern (batch reads in turn 1, batch writes in turn 2).
Returns ``True`` if the loop completed without LLM errors, ``False``
if an LLM call failed (cursor should not advance).
Returns (success, changed_files, last_text).
"""
messages: list[dict[str, Any]] = [{"role": "user", "content": user_msg}]
logger.debug("reflect: starting loop (max %d turns)", max_turns)
changed_files: list[str] = []
last_text: str = ""
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,
@@ -226,21 +227,49 @@ async def _reflection_loop(
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
return False, changed_files, last_text
# Build assistant message.
# Extract tool calls from litellm/OpenAI response object.
tool_calls_raw: list[dict[str, Any]] = []
if resp.raw_response and isinstance(resp.raw_response, dict):
tool_calls_raw = resp.raw_response.get("tool_calls", [])
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
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": resp.content or "",
}
logger.info(
"reflect: LLM responded, text=%d chars, tool_calls=%d",
len(resp.content or ""),
len(tool_calls_raw),
)
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:
# Convert to OpenAI format for the conversation.
assistant_msg["tool_calls"] = [
{
"id": tc["id"],
@@ -254,71 +283,79 @@ async def _reflection_loop(
]
messages.append(assistant_msg)
# No tool calls → agent is done.
if not tool_calls_raw:
logger.debug("reflect: loop done after %d turn(s) (no tool calls)", _turn + 1)
break
# Execute each tool call and append results.
logger.debug("reflect: turn %d — executing %d tool call(s): %s", _turn + 1, len(tool_calls_raw), [tc["name"] for tc in tool_calls_raw])
for tc in tool_calls_raw:
result = _execute_tool(tc["name"], tc.get("input", {}), memory_dir)
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": result,
})
if tc["name"] in ("write_memory_file", "delete_memory_file"):
fname = tc.get("input", {}).get("filename", "")
if fname and not result.startswith("ERROR"):
changed_files.append(fname)
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
return True
return True, changed_files, last_text
# ---------------------------------------------------------------------------
# System prompts
# ---------------------------------------------------------------------------
_FRONTMATTER_EXAMPLE = "\n".join(MEMORY_FRONTMATTER_EXAMPLE)
_CATEGORIES_STR = ", ".join(GLOBAL_MEMORY_CATEGORIES)
_SHORT_REFLECT_SYSTEM = f"""\
You are a reflection agent that distills learnings from a conversation into
persistent memory files. You run in the background after each assistant turn.
You are a reflection agent that distills durable knowledge about the USER
into persistent global memory files. You run in the background after each
assistant turn.
Your goal: identify anything from the recent messages worth remembering across
future sessions user preferences, project context, techniques that worked,
goals, environment details, reference pointers.
Your goal: identify anything from the recent messages worth remembering
about the user across ALL future sessions their profile, preferences,
environment setup, or feedback on assistant behavior.
Memory types: {', '.join(MEMORY_TYPES)}
Memory categories: {_CATEGORIES_STR}
Expected format for each memory file:
{_FRONTMATTER_EXAMPLE}
```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 to see what already exists, then
read_memory_file for any that might need updating.
Turn 1 call list_memory_files to see what exists, then read_memory_file
for any that might need updating.
Turn 2 call write_memory_file for new/updated memories.
Rules:
- Only persist information that would be useful in a *future* conversation.
Skip ephemeral task details, routine tool output, and anything obvious
from the code or git history.
- ONLY persist durable knowledge about the USER who they are, how they
like to work, their tech environment, their feedback on your behavior.
- Do NOT store task-specific details, code patterns, file paths, or
ephemeral session state.
- Keep files concise. Each file should cover ONE topic.
- If an existing memory already covers the learning, UPDATE it rather than
creating a duplicate.
- If there is nothing worth remembering from these messages, do nothing
(just respond with a short note no tool calls needed).
- 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.
- Include a specific, search-friendly description in the frontmatter.
- For user identity/profile information (name, role, background), ALWAYS use
the canonical filename 'user-profile.md'. This is the single source of
truth for user profile data, shared with the settings UI.
- When updating user-profile.md, preserve the '## Identity' section it is
managed by the settings UI. Add/update other sections (Professional Style,
Current Focus, Preferences, etc.) below it.
- Do NOT exceed {MAX_FILE_SIZE_BYTES} bytes per file or {MAX_FILES} total files.
"""
_LONG_REFLECT_SYSTEM = f"""\
You are a reflection agent performing a periodic housekeeping pass over the
memory directory. Your job is to organise, deduplicate, and trim noise from
the accumulated memory files.
global memory directory. Your job is to organise, deduplicate, and trim
noise from the accumulated memory files.
Memory types: {', '.join(MEMORY_TYPES)}
Expected format for each memory file:
{_FRONTMATTER_EXAMPLE}
Memory categories: {_CATEGORIES_STR}
Workflow:
1. list_memory_files to get the full manifest.
@@ -332,29 +369,6 @@ Rules:
- Remove memories that are no longer relevant or are superseded.
- Keep the total collection lean and high-signal.
- Do NOT invent new information only reorganise what exists.
- Do NOT delete or merge MEMORY-*.md diary files. These are daily narratives
managed by a separate process. You may read them for context but should not
modify them.
"""
_DIARY_SYSTEM = """\
You maintain a daily diary entry for an AI colony session. You receive:
(1) Today's existing diary content (may be empty if this is the first entry).
(2) A transcript of recent conversation messages.
Write a cohesive 3-8 sentence narrative about what happened in this session today.
Cover: what the user asked for, what was accomplished, key decisions or obstacles,
and current status.
Rules:
- If an existing diary is provided, rewrite it as a unified narrative incorporating
the new developments. Merge and deduplicate do not simply append.
- Keep the total narrative under 3000 characters.
- Focus on the story arc of the day, not individual tool calls or code details.
- If the recent messages contain nothing substantive (greetings, routine
confirmations), return the existing diary text unchanged.
- Output only the diary prose. No headings, no timestamps, no code fences, no
frontmatter.
"""
@@ -363,29 +377,33 @@ Rules:
# ---------------------------------------------------------------------------
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 short reflection: extract learnings from conversation."""
mem_dir = memory_dir or MEMORY_DIR
"""Run a short reflection: extract user knowledge from conversation."""
logger.info("reflect: starting short reflection for %s", session_dir)
mem_dir = memory_dir or global_memory_dir()
messages = await read_conversation_parts(session_dir)
messages = await _read_conversation_parts(session_dir)
if not messages:
logger.debug("reflect: short — no conversation parts")
logger.info("reflect: no conversation parts found in %s, skipping", session_dir)
return
logger.debug("reflect: short — %d conversation parts", len(messages))
# Build a readable transcript from recent messages.
transcript_lines: list[str] = []
for msg in messages[-50:]:
role = msg.get("role", "")
content = str(msg.get("content", "")).strip()
if role == "tool":
continue # Skip verbose tool results.
if not content:
if role == "tool" or not content:
continue
label = "user" if role == "user" else "assistant"
if len(content) > 800:
@@ -393,6 +411,7 @@ async def run_short_reflection(
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)
@@ -402,23 +421,26 @@ async def run_short_reflection(
f"Timestamp: {datetime.now().isoformat(timespec='minutes')}"
)
await _reflection_loop(llm, _SHORT_REFLECT_SYSTEM, user_msg, mem_dir)
logger.debug("reflect: short reflection done")
_, changed, reason = await _reflection_loop(llm, _SHORT_REFLECT_SYSTEM, user_msg, mem_dir)
if changed:
logger.info("reflect: short reflection done, changed files: %s", changed)
else:
logger.info("reflect: short reflection done, no changes — %s", reason or "no reason")
async def run_long_reflection(
llm: Any,
memory_dir: Path | None = None,
) -> None:
"""Run a long reflection: organise and deduplicate all memories."""
mem_dir = memory_dir or MEMORY_DIR
"""Run a long reflection: organise and deduplicate all global memories."""
logger.debug("reflect: starting long reflection")
mem_dir = memory_dir or global_memory_dir()
files = scan_memory_files(mem_dir)
if not files:
logger.debug("reflect: long — no memory files to organise")
logger.debug("reflect: no memory files, skipping long reflection")
return
logger.debug("reflect: long — organising %d memory files", len(files))
manifest = format_memory_manifest(files)
user_msg = (
f"## Current memory manifest ({len(files)} files)\n\n"
@@ -426,86 +448,43 @@ async def run_long_reflection(
f"Timestamp: {datetime.now().isoformat(timespec='minutes')}"
)
await _reflection_loop(llm, _LONG_REFLECT_SYSTEM, user_msg, mem_dir)
logger.debug("reflect: long reflection done (%d files)", len(files))
_, changed, reason = await _reflection_loop(llm, _LONG_REFLECT_SYSTEM, user_msg, mem_dir)
if changed:
logger.debug("reflect: long reflection done (%d files), changed: %s", len(files), changed)
else:
logger.debug(
"reflect: long reflection done (%d files), no changes — %s",
len(files),
reason or "no reason",
)
async def run_diary_update(
async def run_shutdown_reflection(
session_dir: Path,
llm: Any,
memory_dir: Path | None = None,
) -> None:
"""Update today's diary file with a narrative of recent activity."""
mem_dir = memory_dir or MEMORY_DIR
fname = diary_filename()
diary_path = mem_dir / fname
today_str = datetime.now().strftime("%Y-%m-%d")
# Read existing diary body (strip frontmatter).
existing_body = ""
if diary_path.exists():
try:
raw = diary_path.read_text(encoding="utf-8")
m = re.match(r"^---\s*\n.*?\n---\s*\n?", raw, re.DOTALL)
existing_body = raw[m.end() :].strip() if m else raw.strip()
except OSError:
pass
# Read all conversation messages for context.
messages = await read_conversation_parts(session_dir)
transcript_lines: list[str] = []
for msg in messages[-40:]:
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) > 600:
content = content[:600] + "..."
transcript_lines.append(f"[{label}]: {content}")
if not transcript_lines:
return
transcript = "\n".join(transcript_lines)
user_msg = (
f"## Today's Diary So Far\n\n"
f"{existing_body or '(no entries yet)'}\n\n"
f"## Recent Conversation\n\n"
f"{transcript}\n\n"
f"Date: {today_str}"
)
"""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)
mem_dir = memory_dir or global_memory_dir()
try:
from framework.agents.queen.config import default_config
resp = await llm.acomplete(
messages=[{"role": "user", "content": user_msg}],
system=_DIARY_SYSTEM,
max_tokens=min(default_config.max_tokens, 1024),
)
new_body = (resp.content or "").strip()
if not new_body:
return
doc = build_diary_document(date_str=today_str, body=new_body)
if len(doc.encode("utf-8")) > MAX_FILE_SIZE_BYTES:
new_body = new_body[:2800]
doc = build_diary_document(date_str=today_str, body=new_body)
mem_dir.mkdir(parents=True, exist_ok=True)
diary_path.write_text(doc, encoding="utf-8")
logger.debug("diary: updated %s (%d chars)", fname, len(doc))
await run_short_reflection(session_dir, llm, mem_dir)
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("diary: update failed", exc_info=True)
logger.warning("reflect: shutdown reflection failed", exc_info=True)
_write_error("shutdown reflection")
# ---------------------------------------------------------------------------
# Event-bus integration
# ---------------------------------------------------------------------------
# Run a long reflection every N short reflections.
_LONG_REFLECT_INTERVAL = 5
@@ -514,35 +493,23 @@ async def subscribe_reflection_triggers(
session_dir: Path,
llm: Any,
memory_dir: Path | None = None,
phase_state: Any = 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.runtime.event_bus import EventType
from framework.host.event_bus import EventType
mem_dir = memory_dir or MEMORY_DIR
mem_dir = memory_dir or global_memory_dir()
_lock = asyncio.Lock()
_short_count = 0
_background_tasks: set[asyncio.Task] = set()
async def _on_turn_complete(event: Any) -> None:
nonlocal _short_count
# Only process queen turns.
if getattr(event, "stream_id", None) != "queen":
return
if _lock.locked():
logger.debug("reflect: skipping — reflection already in progress")
return
async def _do_turn_reflect(is_interval: bool, count: int) -> None:
async with _lock:
try:
_short_count += 1
logger.debug("reflect: turn complete — short count %d/%d", _short_count, _LONG_REFLECT_INTERVAL)
if _short_count % _LONG_REFLECT_INTERVAL == 0:
if is_interval:
await run_short_reflection(session_dir, llm, mem_dir)
await run_long_reflection(llm, mem_dir)
else:
@@ -551,46 +518,7 @@ async def subscribe_reflection_triggers(
logger.warning("reflect: reflection failed", exc_info=True)
_write_error("short/long reflection")
# Update daily diary after reflection.
try:
await run_diary_update(session_dir, llm, mem_dir)
except Exception:
logger.warning("reflect: diary update failed", exc_info=True)
# Update recall cache after reflection completes, guaranteeing
# recall sees the current turn's extracted memories.
if phase_state is not None:
try:
from framework.agents.queen.recall_selector import update_recall_cache
await update_recall_cache(
session_dir,
llm,
cache_setter=lambda block: (
setattr(phase_state, "_cached_colony_recall_block", block),
setattr(phase_state, "_cached_recall_block", block),
),
memory_dir=mem_dir,
heading="Colony Memories",
)
await update_recall_cache(
session_dir,
llm,
cache_setter=lambda block: setattr(
phase_state, "_cached_global_recall_block", block
),
memory_dir=getattr(phase_state, "global_memory_dir", None),
heading="Global Memories",
)
except Exception:
logger.debug("recall: cache update failed", exc_info=True)
async def _on_compaction(event: Any) -> None:
if getattr(event, "stream_id", None) != "queen":
return
if _lock.locked():
return
async def _do_compaction_reflect() -> None:
async with _lock:
try:
await run_long_reflection(llm, mem_dir)
@@ -598,6 +526,50 @@ async def subscribe_reflection_triggers(
logger.warning("reflect: compaction-triggered reflection failed", exc_info=True)
_write_error("compaction reflection")
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
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
if _lock.locked():
logger.debug("reflect: skipping, already running (count=%d)", _short_count)
return
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(
@@ -615,68 +587,10 @@ async def subscribe_reflection_triggers(
return sub_ids
async def subscribe_worker_memory_triggers(
event_bus: Any,
llm: Any,
*,
worker_sessions_dir: Path,
colony_memory_dir: Path,
recall_cache: dict[str, str],
) -> list[str]:
"""Subscribe colony memory lifecycle events for worker runs.
Short reflection is now handled synchronously at node handoff in
``WorkerAgent._reflect_colony_memory()``. This function only manages:
- Recall cache initialisation on execution start
- Final long reflection + cleanup on execution end
"""
from framework.runtime.event_bus import EventType
_terminal_lock = asyncio.Lock()
def _is_worker_event(event: Any) -> bool:
return bool(
getattr(event, "execution_id", None)
and getattr(event, "stream_id", None) not in ("queen", "judge")
)
async def _on_execution_started(event: Any) -> None:
if not _is_worker_event(event):
return
if event.execution_id is not None:
recall_cache[event.execution_id] = ""
async def _on_execution_terminal(event: Any) -> None:
if not _is_worker_event(event):
return
execution_id = event.execution_id
if execution_id is None:
return
async with _terminal_lock:
try:
await run_long_reflection(llm, colony_memory_dir)
except Exception:
logger.warning("reflect: worker final reflection failed", exc_info=True)
_write_error("worker final reflection")
finally:
recall_cache.pop(execution_id, None)
return [
event_bus.subscribe(
event_types=[EventType.EXECUTION_STARTED],
handler=_on_execution_started,
),
event_bus.subscribe(
event_types=[EventType.EXECUTION_COMPLETED, EventType.EXECUTION_FAILED],
handler=_on_execution_terminal,
),
]
def _write_error(context: str) -> None:
"""Best-effort write of the last traceback to an error file."""
try:
error_path = MEMORY_DIR / ".reflection_error.txt"
error_path = global_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()}",
@@ -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()
+30 -54
View File
@@ -2,17 +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 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
@@ -20,86 +25,57 @@ 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, 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)
# Register MCP registry commands (mcp install, mcp add, ...)
from framework.runner.mcp_registry_cli import register_mcp_commands
# MCP server registry
from framework.loader.mcp_registry_cli import register_mcp_commands
register_mcp_commands(subparsers)
+115 -14
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:
@@ -221,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:
@@ -247,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:
@@ -258,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:
@@ -269,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:
@@ -280,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.
@@ -422,7 +523,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:
+2
View File
@@ -51,6 +51,7 @@ from .key_storage import (
from .models import (
CredentialDecryptionError,
CredentialError,
CredentialExpiredError,
CredentialKey,
CredentialKeyNotFoundError,
CredentialNotFoundError,
@@ -136,6 +137,7 @@ __all__ = [
"CredentialNotFoundError",
"CredentialKeyNotFoundError",
"CredentialRefreshError",
"CredentialExpiredError",
"CredentialValidationError",
"CredentialDecryptionError",
# Key storage (bootstrap credentials)
+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."""
+21 -7
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__)
@@ -533,7 +533,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 +544,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 +604,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 = []
+155 -26
View File
@@ -161,6 +161,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 +194,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:
@@ -217,7 +225,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 +266,154 @@ 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):
+59 -8
View File
@@ -19,6 +19,7 @@ from typing import Any
from pydantic import SecretStr
from .models import (
CredentialExpiredError,
CredentialKey,
CredentialObject,
CredentialRefreshError,
@@ -177,6 +178,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 +187,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 +201,9 @@ 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 +213,46 @@ 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 +261,14 @@ 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 +539,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 +571,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:
-65
View File
@@ -1,65 +0,0 @@
"""Graph structures: Goals, Nodes, Edges, and Execution."""
from framework.graph.context import GraphContext
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
from framework.graph.worker_agent import (
Activation,
FanOutTag,
FanOutTracker,
WorkerAgent,
WorkerCompletion,
WorkerLifecycle,
)
__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",
# Worker Agent
"WorkerAgent",
"WorkerLifecycle",
"WorkerCompletion",
"Activation",
"FanOutTag",
"FanOutTracker",
"GraphContext",
]
@@ -1,6 +0,0 @@
"""EventLoopNode subpackage — modular components of the event loop orchestrator.
All public symbols are re-exported by the parent ``event_loop_node.py`` for
backward compatibility. Internal consumers may import directly from these
submodules for clarity.
"""
@@ -1,378 +0,0 @@
"""Subagent execution for the event loop.
Handles the full subagent lifecycle: validation, context setup, tool filtering,
conversation store derivation, execution, and cleanup.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from framework.graph.conversation import ConversationStore
from framework.graph.event_loop.judge_pipeline import SubagentJudge
from framework.graph.event_loop.types import LoopConfig, OutputAccumulator
from framework.graph.node import DataBuffer, NodeContext
from framework.llm.provider import ToolResult, ToolUse
from framework.runtime.event_bus import EventBus
if TYPE_CHECKING:
from framework.graph.event_loop_node import EventLoopNode
logger = logging.getLogger(__name__)
async def execute_subagent(
ctx: NodeContext,
agent_id: str,
task: str,
*,
config: LoopConfig,
event_loop_node_cls: type[EventLoopNode],
escalation_receiver_cls: Callable[[], Any],
accumulator: OutputAccumulator | None = None,
event_bus: EventBus | None = None,
tool_executor: Callable[[ToolUse], ToolResult | Awaitable[ToolResult]] | None = None,
conversation_store: ConversationStore | None = None,
subagent_instance_counter: dict[str, int] | None = None,
) -> ToolResult:
"""Execute a subagent and return the result as a ToolResult.
The subagent:
- Gets a fresh conversation with just the task
- Has read-only access to the parent's readable memory
- Cannot delegate to its own subagents (prevents recursion)
- Returns its output in structured JSON format
Args:
ctx: Parent node's context (for memory, tools, LLM access).
agent_id: The node ID of the subagent to invoke.
task: The task description to give the subagent.
accumulator: Parent's OutputAccumulator.
event_bus: EventBus for lifecycle events.
config: LoopConfig for iteration/tool limits.
tool_executor: Tool executor callable.
conversation_store: Parent conversation store (for deriving subagent store).
subagent_instance_counter: Mutable counter dict for unique subagent paths.
Returns:
ToolResult with structured JSON output.
"""
# Log subagent invocation start
logger.info(
"\n" + "=" * 60 + "\n"
"🤖 SUBAGENT INVOCATION\n"
"=" * 60 + "\n"
"Parent Node: %s\n"
"Subagent ID: %s\n"
"Task: %s\n" + "=" * 60,
ctx.node_id,
agent_id,
task[:500] + "..." if len(task) > 500 else task,
)
# 1. Validate agent exists in registry
if agent_id not in ctx.node_registry:
return ToolResult(
tool_use_id="",
content=json.dumps(
{
"message": f"Sub-agent '{agent_id}' not found in registry",
"data": None,
"metadata": {"agent_id": agent_id, "success": False, "error": "not_found"},
}
),
is_error=True,
)
subagent_spec = ctx.node_registry[agent_id]
# 2. Create read-only memory snapshot
parent_data = ctx.buffer.read_all()
# Merge in-flight outputs from the parent's accumulator.
if accumulator:
for key, value in accumulator.to_dict().items():
if key not in parent_data:
parent_data[key] = value
subagent_buffer = DataBuffer()
for key, value in parent_data.items():
subagent_buffer.write(key, value, validate=False)
read_keys = set(parent_data.keys()) | set(subagent_spec.input_keys or [])
scoped_buffer = subagent_buffer.with_permissions(
read_keys=list(read_keys),
write_keys=[], # Read-only!
)
# 2b. Compute instance counter early so the callback and child context
# share the same stable node_id for this subagent invocation.
if subagent_instance_counter is not None:
subagent_instance_counter.setdefault(agent_id, 0)
subagent_instance_counter[agent_id] += 1
subagent_instance = str(subagent_instance_counter[agent_id])
else:
subagent_instance = "1"
if subagent_instance == "1":
sa_node_id = f"{ctx.node_id}:subagent:{agent_id}"
else:
sa_node_id = f"{ctx.node_id}:subagent:{agent_id}:{subagent_instance}"
# 2c. Set up report callback (one-way channel to parent / event bus)
subagent_reports: list[dict] = []
async def _report_callback(
message: str,
data: dict | None = None,
*,
wait_for_response: bool = False,
) -> str | None:
subagent_reports.append({"message": message, "data": data, "timestamp": time.time()})
if event_bus:
await event_bus.emit_subagent_report(
stream_id=ctx.node_id,
node_id=sa_node_id,
subagent_id=agent_id,
message=message,
data=data,
execution_id=ctx.execution_id,
)
if not wait_for_response:
return None
if not event_bus:
logger.warning(
"Subagent '%s' requested user response but no event_bus available",
agent_id,
)
return None
# Create isolated receiver and register for input routing
import uuid
escalation_id = f"{ctx.node_id}:escalation:{uuid.uuid4().hex[:8]}"
receiver = escalation_receiver_cls()
registry = ctx.shared_node_registry
registry[escalation_id] = receiver
try:
await event_bus.emit_escalation_requested(
stream_id=ctx.stream_id or ctx.node_id,
node_id=escalation_id,
reason=f"Subagent report (wait_for_response) from {agent_id}",
context=message,
execution_id=ctx.execution_id,
)
# Block until queen responds
return await receiver.wait()
finally:
registry.pop(escalation_id, None)
# 3. Filter tools for subagent
subagent_tool_names = set(subagent_spec.tools or [])
tool_source = ctx.all_tools if ctx.all_tools else ctx.available_tools
# GCU auto-population
if subagent_spec.node_type == "gcu" and not subagent_tool_names:
subagent_tools = [t for t in tool_source if t.name != "delegate_to_sub_agent"]
else:
subagent_tools = [
t
for t in tool_source
if t.name in subagent_tool_names and t.name != "delegate_to_sub_agent"
]
missing = subagent_tool_names - {t.name for t in subagent_tools}
if missing:
logger.warning(
"Subagent '%s' requested tools not found in catalog: %s",
agent_id,
sorted(missing),
)
logger.info(
"📦 Subagent '%s' configuration:\n"
" - System prompt: %s\n"
" - Tools available (%d): %s\n"
" - Memory keys inherited: %s",
agent_id,
(subagent_spec.system_prompt[:200] + "...")
if subagent_spec.system_prompt and len(subagent_spec.system_prompt) > 200
else subagent_spec.system_prompt,
len(subagent_tools),
[t.name for t in subagent_tools],
list(parent_data.keys()),
)
# 4. Build subagent context
max_iter = min(config.max_iterations, 10)
subagent_ctx = NodeContext(
runtime=ctx.runtime,
node_id=sa_node_id,
node_spec=subagent_spec,
buffer=scoped_buffer,
input_data={"task": task, **parent_data},
llm=ctx.llm,
available_tools=subagent_tools,
goal_context=(
f"Your specific task: {task}\n\n"
f"COMPLETION REQUIREMENTS:\n"
f"When your task is done, you MUST call set_output() "
f"for each required key: {subagent_spec.output_keys}\n"
f"Alternatively, call report_to_parent(mark_complete=true) "
f"with your findings in message/data.\n"
f"You have a maximum of {max_iter} turns to complete this task."
),
goal=ctx.goal,
max_tokens=ctx.max_tokens,
runtime_logger=ctx.runtime_logger,
is_subagent_mode=True, # Prevents nested delegation
report_callback=_report_callback,
node_registry={}, # Empty - no nested subagents
shared_node_registry=ctx.shared_node_registry, # For escalation routing
)
# 5. Create and execute subagent EventLoopNode
subagent_conv_store = None
if conversation_store is not None:
from framework.storage.conversation_store import FileConversationStore
parent_base = getattr(conversation_store, "_base", None)
if parent_base is not None:
conversations_dir = parent_base.parent
subagent_dir_name = f"{agent_id}-{subagent_instance}"
subagent_store_path = conversations_dir / subagent_dir_name
subagent_conv_store = FileConversationStore(base_path=subagent_store_path)
# Derive a subagent-scoped spillover dir
subagent_spillover = None
if config.spillover_dir:
subagent_spillover = str(Path(config.spillover_dir) / agent_id / subagent_instance)
subagent_node = event_loop_node_cls(
event_bus=event_bus,
judge=SubagentJudge(task=task, max_iterations=max_iter),
config=LoopConfig(
max_iterations=max_iter,
max_tool_calls_per_turn=config.max_tool_calls_per_turn,
tool_call_overflow_margin=config.tool_call_overflow_margin,
max_context_tokens=config.max_context_tokens,
stall_detection_threshold=config.stall_detection_threshold,
max_tool_result_chars=config.max_tool_result_chars,
spillover_dir=subagent_spillover,
),
tool_executor=tool_executor,
conversation_store=subagent_conv_store,
)
# Inject a unique GCU browser profile for this subagent
_profile_token = None
try:
from gcu.browser.session import set_active_profile as _set_gcu_profile
_profile_token = _set_gcu_profile(f"{agent_id}-{subagent_instance}")
except ImportError:
pass # GCU tools not installed; no-op
try:
logger.info("🚀 Starting subagent '%s' execution...", agent_id)
start_time = time.time()
result = await subagent_node.execute(subagent_ctx)
latency_ms = int((time.time() - start_time) * 1000)
separator = "-" * 60
logger.info(
"\n%s\n"
"✅ SUBAGENT '%s' COMPLETED\n"
"%s\n"
"Success: %s\n"
"Latency: %dms\n"
"Tokens used: %s\n"
"Output keys: %s\n"
"%s",
separator,
agent_id,
separator,
result.success,
latency_ms,
result.tokens_used,
list(result.output.keys()) if result.output else [],
separator,
)
result_json = {
"message": (
f"Sub-agent '{agent_id}' completed successfully"
if result.success
else f"Sub-agent '{agent_id}' failed: {result.error}"
),
"data": result.output,
"reports": subagent_reports if subagent_reports else None,
"metadata": {
"agent_id": agent_id,
"success": result.success,
"tokens_used": result.tokens_used,
"latency_ms": latency_ms,
"report_count": len(subagent_reports),
},
}
return ToolResult(
tool_use_id="",
content=json.dumps(result_json, indent=2, default=str),
is_error=not result.success,
)
except Exception as e:
logger.exception(
"\n" + "!" * 60 + "\n❌ SUBAGENT '%s' FAILED\nError: %s\n" + "!" * 60,
agent_id,
str(e),
)
result_json = {
"message": f"Sub-agent '{agent_id}' raised exception: {e}",
"data": None,
"metadata": {
"agent_id": agent_id,
"success": False,
"error": str(e),
},
}
return ToolResult(
tool_use_id="",
content=json.dumps(result_json, indent=2),
is_error=True,
)
finally:
# Restore the GCU profile context
if _profile_token is not None:
from gcu.browser.session import _active_profile as _gcu_profile_var
_gcu_profile_var.reset(_profile_token)
# Stop the browser session for this subagent's profile
if tool_executor is not None:
_subagent_profile = f"{agent_id}-{subagent_instance}"
try:
_stop_use = ToolUse(
id="gcu-cleanup",
name="browser_stop",
input={"profile": _subagent_profile},
)
_stop_result = tool_executor(_stop_use)
if asyncio.iscoroutine(_stop_result) or asyncio.isfuture(_stop_result):
await _stop_result
except Exception as _gcu_exc:
logger.warning(
"GCU browser_stop failed for profile %r: %s",
_subagent_profile,
_gcu_exc,
)
+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
@@ -108,14 +108,10 @@ 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"
# Worker agent lifecycle (event-driven graph execution)
# Worker agent lifecycle
WORKER_COMPLETED = "worker_completed"
WORKER_FAILED = "worker_failed"
@@ -135,21 +131,19 @@ class EventType(StrEnum):
# Execution resurrection (auto-restart on non-fatal failure)
EXECUTION_RESURRECTED = "execution_resurrected"
# Graph lifecycle (session manager → frontend)
WORKER_GRAPH_LOADED = "worker_graph_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 +168,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 +181,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 +202,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 +384,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 +395,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 +410,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
@@ -518,8 +512,8 @@ 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
@@ -1029,24 +1023,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 +1047,6 @@ class EventBus:
)
)
async def emit_edge_traversed(
self,
stream_id: str,
source_node: str,
target_node: str,
edge_condition: str = "",
execution_id: str | None = None,
) -> None:
"""Emit edge traversed event."""
await self.publish(
AgentEvent(
type=EventType.EDGE_TRAVERSED,
stream_id=stream_id,
node_id=source_node,
execution_id=execution_id,
data={
"source_node": source_node,
"target_node": target_node,
"edge_condition": edge_condition,
},
)
)
async def emit_worker_completed(
self,
stream_id: str,
@@ -1208,15 +1161,25 @@ 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},
data={
"request_id": request_id,
"reason": reason,
"context": context,
},
)
)
@@ -1297,7 +1260,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:
"""
@@ -1308,7 +1271,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:
@@ -1329,7 +1292,7 @@ class EventBus:
filter_stream=stream_id,
filter_node=node_id,
filter_execution=execution_id,
filter_graph=graph_id,
filter_colony=colony_id,
)
try:
@@ -18,18 +18,18 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
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, SharedBufferManager
from framework.runtime.stream_runtime import StreamRuntime, StreamRuntimeAdapter
from framework.orchestrator.checkpoint_config import CheckpointConfig
from framework.orchestrator.orchestrator import ExecutionResult, Orchestrator
from framework.host.event_bus import EventBus
from framework.host.shared_state import IsolationLevel, SharedBufferManager
from framework.host.stream_runtime import StreamDecisionTracker, StreamRuntimeAdapter
if TYPE_CHECKING:
from framework.graph.edge import GraphSpec
from framework.graph.goal import Goal
from framework.orchestrator.edge import GraphSpec
from framework.orchestrator.goal import Goal
from framework.llm.provider import LLMProvider, Tool
from framework.runtime.event_bus import AgentEvent
from framework.runtime.outcome_aggregator import OutcomeAggregator
from framework.host.event_bus import AgentEvent
from framework.host.outcome_aggregator import OutcomeAggregator
from framework.storage.concurrent import ConcurrentStorage
from framework.storage.session_store import SessionStore
@@ -133,7 +133,7 @@ class ExecutionContext:
status: str = "pending" # pending, running, completed, failed, paused
class ExecutionStream:
class ExecutionManager:
"""
Manages concurrent executions for a single entry point.
@@ -172,7 +172,7 @@ class ExecutionStream:
goal: "Goal",
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,
@@ -192,10 +192,6 @@ class ExecutionStream:
context_warn_ratio: float | None = None,
batch_init_nudge: str | None = None,
dynamic_memory_provider_factory: Callable[[str], Callable[[], str] | None] | None = None,
colony_memory_dir: Any = None,
colony_worker_sessions_dir: Any = None,
colony_recall_cache: dict[str, str] | None = None,
colony_reflect_llm: Any = None,
):
"""
Initialize execution stream.
@@ -251,10 +247,6 @@ class ExecutionStream:
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
self._colony_memory_dir = colony_memory_dir
self._colony_worker_sessions_dir = colony_worker_sessions_dir
self._colony_recall_cache = colony_recall_cache
self._colony_reflect_llm = colony_reflect_llm
_es_logger = logging.getLogger(__name__)
if protocols_prompt:
@@ -270,16 +262,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] = {}
@@ -309,7 +300,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(
@@ -434,7 +425,7 @@ class ExecutionStream:
# 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(
@@ -676,7 +667,7 @@ 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
@@ -705,12 +696,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,
@@ -735,10 +721,6 @@ class ExecutionStream:
if self._dynamic_memory_provider_factory is not None
else None
),
colony_memory_dir=self._colony_memory_dir,
colony_worker_sessions_dir=self._colony_worker_sessions_dir,
colony_recall_cache=self._colony_recall_cache,
colony_reflect_llm=self._colony_reflect_llm,
)
# Track executor so inject_input() can reach EventLoopNode instances
self._active_executors[execution_id] = executor
@@ -775,7 +757,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(
@@ -1131,7 +1113,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
@@ -1228,9 +1210,21 @@ class ExecutionStream:
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.
# respond to cancellation quickly.
done, _ = await asyncio.wait({task}, timeout=5.0)
if not done:
# Task didn't finish within timeout — clean up bookkeeping now
# so the session doesn't think it still has running executions.
# The task will continue winding down in the background and its
# finally block will harmlessly pop already-removed keys.
logger.warning(
"Execution %s did not finish within cancel timeout; force-cleaning bookkeeping",
execution_id,
)
async with self._lock:
self._active_executions.pop(execution_id, None)
self._execution_tasks.pop(execution_id, None)
self._active_executors.pop(execution_id, None)
return True
return False
+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}
+63
View File
@@ -0,0 +1,63 @@
"""Stub — shared state removed in colony refactor."""
import asyncio
import logging
import time
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
logger = logging.getLogger(__name__)
class IsolationLevel(StrEnum):
ISOLATED = "isolated"
SHARED = "shared"
SYNCHRONIZED = "synchronized"
class StateScope(StrEnum):
EXECUTION = "execution"
STREAM = "stream"
GLOBAL = "global"
class SharedBufferManager:
def __init__(self):
self._global_state: dict[str, Any] = {}
self._stream_states: dict[str, dict[str, Any]] = {}
self._execution_states: dict[str, dict[str, Any]] = {}
self._lock = asyncio.Lock()
def create_buffer(
self,
execution_id: str,
stream_id: str = "",
isolation: IsolationLevel = IsolationLevel.ISOLATED,
):
execution_key = f"{stream_id}:{execution_id}"
if execution_key not in self._execution_states:
self._execution_states[execution_key] = {}
return self._execution_states[execution_key]
def get_stream_state(self, stream_id: str) -> dict[str, Any]:
return self._stream_states.setdefault(stream_id, {})
def get_global_state(self) -> dict[str, Any]:
return self._global_state
def cleanup_execution(self, execution_id: str, stream_id: str = "") -> None:
"""Drop the per-execution state bucket.
No-op when the key is absent. Called from
``ExecutionManager._run_execution``'s finally block. Before this
stub existed, the call raised ``AttributeError`` on every
execution teardown because the SharedBufferManager stub had no
such method.
"""
execution_key = f"{stream_id}:{execution_id}"
self._execution_states.pop(execution_key, None)
def get_recent_changes(self, limit: int = 10) -> list[dict[str, Any]]:
"""Compat stub — returns empty list. Shared buffer was removed."""
return []
@@ -10,20 +10,17 @@ import asyncio
import logging
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Any
from typing import Any
from framework.observability import set_trace_context
from framework.schemas.decision import Decision, DecisionType, Option, Outcome
from framework.schemas.run import Run, RunStatus
from framework.storage.concurrent import ConcurrentStorage
if TYPE_CHECKING:
from framework.runtime.outcome_aggregator import OutcomeAggregator
logger = logging.getLogger(__name__)
class StreamRuntime:
class StreamDecisionTracker:
"""
Thread-safe runtime for a single execution stream.
@@ -75,7 +72,6 @@ class StreamRuntime:
self,
stream_id: str,
storage: ConcurrentStorage,
outcome_aggregator: "OutcomeAggregator | None" = None,
):
"""
Initialize stream runtime.
@@ -83,11 +79,9 @@ class StreamRuntime:
Args:
stream_id: Unique identifier for this stream
storage: Concurrent storage backend
outcome_aggregator: Optional aggregator for cross-stream evaluation
"""
self.stream_id = stream_id
self._storage = storage
self._outcome_aggregator = outcome_aggregator
# Track runs by execution_id (thread-safe via lock)
self._runs: dict[str, Run] = {}
@@ -268,14 +262,6 @@ class StreamRuntime:
run.add_decision(decision)
# Report to outcome aggregator if available
if self._outcome_aggregator:
self._outcome_aggregator.record_decision(
stream_id=self.stream_id,
execution_id=execution_id,
decision=decision,
)
return decision_id
def record_outcome(
@@ -321,15 +307,6 @@ class StreamRuntime:
run.record_outcome(decision_id, outcome)
# Report to outcome aggregator if available
if self._outcome_aggregator:
self._outcome_aggregator.record_outcome(
stream_id=self.stream_id,
execution_id=execution_id,
decision_id=decision_id,
outcome=outcome,
)
# === PROBLEM RECORDING ===
def report_problem(
@@ -431,7 +408,7 @@ class StreamRuntimeAdapter:
by providing the same API as Runtime but routing to a specific execution.
"""
def __init__(self, stream_runtime: StreamRuntime, execution_id: str):
def __init__(self, stream_runtime: StreamDecisionTracker, execution_id: str):
"""
Create adapter for a specific execution.
@@ -13,7 +13,7 @@ from dataclasses import dataclass
from aiohttp import web
from framework.runtime.event_bus import EventBus
from framework.host.event_bus import EventBus
logger = logging.getLogger(__name__)
+423
View File
@@ -0,0 +1,423 @@
"""Worker — a single autonomous AgentLoop clone in a colony.
Two modes:
**Ephemeral (default)**: runs a single AgentLoop execution with a task,
emits a `SUBAGENT_REPORT` event on termination (success, partial, or
failed), and terminates. Used for parallel fan-out from the overseer.
**Persistent (``persistent=True``)**: runs an initial AgentLoop execution
(usually idle, no task) and then loops forever, receiving user chat via
``inject(message)`` and pumping each message into the already-running
agent loop via ``inject_event``. Used for the colony's long-running
client-facing overseer.
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class WorkerStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
STOPPED = "stopped"
@dataclass
class WorkerResult:
output: dict[str, Any] = field(default_factory=dict)
error: str | None = None
tokens_used: int = 0
duration_seconds: float = 0.0
# New: structured report fields. Populated by report_to_parent tool or
# synthesised from AgentResult on termination.
status: str = "success" # "success" | "partial" | "failed" | "timeout" | "stopped"
summary: str = ""
data: dict[str, Any] = field(default_factory=dict)
@dataclass
class WorkerInfo:
id: str
task: str
status: WorkerStatus
started_at: float = 0.0
result: WorkerResult | None = None
class Worker:
"""A single autonomous clone in a colony.
Ephemeral mode (default):
- PENDING RUNNING COMPLETED/FAILED/STOPPED, one shot, terminates.
Persistent mode (``persistent=True``, used by the overseer):
- PENDING RUNNING (never transitions out by itself).
- Receives user chat via ``inject(message)``.
- Each injected message is pumped into the running AgentLoop via
``inject_event``, triggering another turn.
"""
def __init__(
self,
worker_id: str,
task: str,
agent_loop: Any,
context: Any,
event_bus: Any = None,
colony_id: str = "",
persistent: bool = False,
storage_path: Path | None = None,
):
self.id = worker_id
self.task = task
self.status = WorkerStatus.PENDING
self._agent_loop = agent_loop
self._context = context
self._event_bus = event_bus
self._colony_id = colony_id
self._persistent = persistent
# Canonical on-disk home for this worker (conversations, events,
# result.json, data). Required when seed_conversation() is used —
# we deliberately do NOT fall back to CWD, which previously caused
# conversation parts to leak into the process working directory.
self._storage_path: Path | None = (
Path(storage_path) if storage_path is not None else None
)
self._task_handle: asyncio.Task | None = None
self._started_at: float = 0.0
self._result: WorkerResult | None = None
self._input_queue: asyncio.Queue[str | None] = asyncio.Queue()
# Set by AgentLoop when the worker's LLM calls ``report_to_parent``.
# Takes precedence over the synthesised report from AgentResult.
self._explicit_report: dict[str, Any] | None = None
# Back-reference so AgentLoop's report_to_parent handler can call
# record_explicit_report on the owning Worker. The agent_loop's
# _owner_worker attribute is set here during construction.
if agent_loop is not None:
agent_loop._owner_worker = self
@property
def info(self) -> WorkerInfo:
return WorkerInfo(
id=self.id,
task=self.task,
status=self.status,
started_at=self._started_at,
result=self._result,
)
@property
def is_active(self) -> bool:
return self.status in (WorkerStatus.PENDING, WorkerStatus.RUNNING)
@property
def is_persistent(self) -> bool:
return self._persistent
@property
def agent_loop(self) -> Any:
"""The wrapped AgentLoop. Used by the SessionManager chat path."""
return self._agent_loop
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def run(self) -> WorkerResult:
"""Entry point for the worker's background task.
Ephemeral workers run ``AgentLoop.execute`` once and terminate,
emitting a ``SUBAGENT_REPORT`` event.
Persistent workers run the initial execute then loop forever
processing injected user messages.
"""
self.status = WorkerStatus.RUNNING
self._started_at = time.monotonic()
try:
result = await self._agent_loop.execute(self._context)
duration = time.monotonic() - self._started_at
if result.success:
self.status = WorkerStatus.COMPLETED
self._result = self._build_result(
result, duration, default_status="success"
)
else:
self.status = WorkerStatus.FAILED
self._result = self._build_result(
result, duration, default_status="failed"
)
await self._emit_terminal_events(result)
if self._persistent:
# Persistent worker: keep the loop alive, pump injected
# messages forever. Status stays RUNNING; info reflects
# current progress.
self.status = WorkerStatus.RUNNING
await self._persistent_input_loop()
return self._result # type: ignore[return-value]
except asyncio.CancelledError:
self.status = WorkerStatus.STOPPED
duration = time.monotonic() - self._started_at
self._result = WorkerResult(
error="Worker stopped by queen",
duration_seconds=duration,
status="stopped",
summary="Worker was cancelled before completion.",
)
await self._emit_terminal_events(None, force_status="stopped")
return self._result
except Exception as exc:
self.status = WorkerStatus.FAILED
duration = time.monotonic() - self._started_at
self._result = WorkerResult(
error=str(exc),
duration_seconds=duration,
status="failed",
summary=f"Worker crashed: {exc}",
)
logger.error("Worker %s failed: %s", self.id, exc, exc_info=True)
await self._emit_terminal_events(None, force_status="failed")
return self._result
async def _persistent_input_loop(self) -> None:
"""Pump injected messages into the running AgentLoop forever.
Each ``inject(msg)`` call puts a string on ``_input_queue``. This
loop awaits it and calls ``agent_loop.inject_event(msg)`` which
wakes the loop's pending user-input gate.
"""
while True:
msg = await self._input_queue.get()
if msg is None:
# Sentinel: shutdown
return
try:
await self._agent_loop.inject_event(msg, is_client_input=True)
except Exception:
logger.exception(
"Overseer %s: inject_event failed for injected message",
self.id,
)
# ------------------------------------------------------------------
# Reporting
# ------------------------------------------------------------------
def record_explicit_report(
self,
status: str,
summary: str,
data: dict[str, Any] | None = None,
) -> None:
"""Called by AgentLoop when the worker's LLM invokes ``report_to_parent``.
Stores the report so that when ``run()`` reaches the termination
block, the explicit report wins over a synthesised one.
"""
self._explicit_report = {
"status": status,
"summary": summary,
"data": data or {},
}
def _build_result(
self,
agent_result: Any,
duration: float,
default_status: str,
) -> WorkerResult:
"""Construct a WorkerResult from AgentResult + optional explicit report."""
explicit = self._explicit_report
if explicit is not None:
return WorkerResult(
output=dict(agent_result.output or {}),
error=agent_result.error,
tokens_used=getattr(agent_result, "tokens_used", 0),
duration_seconds=duration,
status=explicit["status"],
summary=explicit["summary"],
data=explicit["data"],
)
# Synthesise a minimal report from AgentResult
if agent_result.success:
summary = f"Completed task '{self.task[:80]}' with {len(agent_result.output or {})} outputs."
data = dict(agent_result.output or {})
else:
summary = f"Task '{self.task[:80]}' failed: {agent_result.error or 'unknown'}"
data = {}
return WorkerResult(
output=dict(agent_result.output or {}),
error=agent_result.error,
tokens_used=getattr(agent_result, "tokens_used", 0),
duration_seconds=duration,
status=default_status,
summary=summary,
data=data,
)
async def _emit_terminal_events(
self,
agent_result: Any,
force_status: str | None = None,
) -> None:
"""Emit EXECUTION_COMPLETED/FAILED AND SUBAGENT_REPORT on termination.
Both events are published so that consumers that listen for
either shape keep working. The SUBAGENT_REPORT carries the
structured summary the overseer actually cares about.
"""
if self._event_bus is None:
return
from framework.host.event_bus import AgentEvent, EventType
# EXECUTION_COMPLETED / EXECUTION_FAILED (backwards-compat)
if agent_result is not None:
lifecycle_type = (
EventType.EXECUTION_COMPLETED
if agent_result.success
else EventType.EXECUTION_FAILED
)
await self._event_bus.publish(
AgentEvent(
type=lifecycle_type,
stream_id=self._context.stream_id or self.id,
node_id=self.id,
execution_id=self._context.execution_id or self.id,
data={
"worker_id": self.id,
"colony_id": self._colony_id,
"task": self.task,
"success": agent_result.success,
"error": agent_result.error,
"output_keys": (
list(agent_result.output.keys())
if agent_result.output
else []
),
},
)
)
# SUBAGENT_REPORT — the structured channel the overseer awaits
result = self._result
if result is None:
return
await self._event_bus.publish(
AgentEvent(
type=EventType.SUBAGENT_REPORT,
stream_id=self._context.stream_id or self.id,
node_id=self.id,
execution_id=self._context.execution_id or self.id,
data={
"worker_id": self.id,
"colony_id": self._colony_id,
"task": self.task,
"status": force_status or result.status,
"summary": result.summary,
"data": result.data,
"error": result.error,
"duration_seconds": result.duration_seconds,
"tokens_used": result.tokens_used,
},
)
)
# ------------------------------------------------------------------
# External control
# ------------------------------------------------------------------
async def start_background(self) -> None:
"""Spawn the worker's run() as an asyncio background task."""
self._task_handle = asyncio.create_task(self.run())
async def stop(self) -> None:
"""Cancel the worker's background task, if any."""
if self._persistent:
# Signal the input loop to exit cleanly first
await self._input_queue.put(None)
if self._task_handle and not self._task_handle.done():
self._task_handle.cancel()
try:
await self._task_handle
except asyncio.CancelledError:
pass
async def inject(self, message: str) -> None:
"""Pump a user message into the worker.
For ephemeral workers this is rarely used (they don't take
follow-up input). For persistent overseers this is the chat
injection path.
"""
await self._input_queue.put(message)
async def seed_conversation(self, messages: list[dict[str, Any]]) -> None:
"""Pre-populate the worker's ConversationStore before starting.
Used when forking a queen DM into a colony: the DM's prior
conversation becomes the colony overseer's starting point so the
overseer resumes mid-thought instead of greeting the user fresh.
``messages`` is a list of dicts matching the ConversationStore's
part format: ``{seq, role, content, tool_calls, tool_use_id,
created_at, phase}``. The caller is responsible for rewriting
``agent_id`` to match the new worker, and for numbering ``seq``
monotonically from 0.
Must be called BEFORE ``start_background``.
"""
if self.status != WorkerStatus.PENDING:
raise RuntimeError(
f"seed_conversation must be called before start_background "
f"(worker {self.id} is {self.status})"
)
# Write parts directly to the worker's on-disk conversation store
# so that the AgentLoop's FileConversationStore picks them up when
# NodeConversation loads from disk. We require an explicit
# storage_path — falling back to CWD previously caused part files
# to leak into the process working directory.
if self._storage_path is None:
raise RuntimeError(
f"seed_conversation requires storage_path to be set on "
f"Worker {self.id}; construct Worker with storage_path=..."
)
parts_dir = self._storage_path / "conversations" / "parts"
parts_dir.mkdir(parents=True, exist_ok=True)
import json
for i, msg in enumerate(messages):
msg = dict(msg) # copy
msg.setdefault("seq", i)
msg.setdefault("agent_id", self.id)
part_file = parts_dir / f"{msg['seq']:010d}.json"
part_file.write_text(json.dumps(msg), encoding="utf-8")
logger.info(
"Worker %s: seeded %d messages into %s",
self.id,
len(messages),
parts_dir,
)
+101
View File
@@ -0,0 +1,101 @@
"""Thread-safe API key pool with round-robin rotation and health tracking.
When multiple API keys are configured, the pool rotates through them on each
request. Keys that hit rate limits are temporarily cooled-down so the next
call automatically uses a healthy key -- no sleep required.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class KeyHealth:
"""Per-key health counters."""
rate_limited_until: float = 0.0 # monotonic timestamp
consecutive_errors: int = 0
total_requests: int = 0
total_successes: int = 0
class KeyPool:
"""Round-robin key pool with health tracking.
Thread-safe: all mutations protected by a lock so concurrent LLM calls
(e.g. parallel tool execution in EventLoopNode) don't race.
"""
def __init__(self, keys: list[str]) -> None:
if not keys:
raise ValueError("KeyPool requires at least one key")
self._keys = list(keys)
self._index = 0
self._health: dict[str, KeyHealth] = {k: KeyHealth() for k in keys}
self._lock = threading.Lock()
@property
def size(self) -> int:
return len(self._keys)
def get_key(self) -> str:
"""Return the next healthy key (round-robin).
If every key is currently rate-limited, returns the one whose cooldown
expires soonest so the caller can proceed with minimal delay.
"""
with self._lock:
now = time.monotonic()
for _ in range(len(self._keys)):
key = self._keys[self._index]
self._index = (self._index + 1) % len(self._keys)
health = self._health[key]
if health.rate_limited_until <= now:
health.total_requests += 1
return key
# All rate-limited -- pick the one that expires soonest.
soonest = min(self._keys, key=lambda k: self._health[k].rate_limited_until)
self._health[soonest].total_requests += 1
return soonest
def mark_rate_limited(self, key: str, retry_after: float = 60.0) -> None:
"""Mark *key* as rate-limited for *retry_after* seconds."""
with self._lock:
health = self._health.get(key)
if health:
health.rate_limited_until = time.monotonic() + retry_after
health.consecutive_errors += 1
logger.info(
"[key-pool] Key ...%s rate-limited for %.0fs (errors=%d)",
key[-6:],
retry_after,
health.consecutive_errors,
)
def mark_success(self, key: str) -> None:
"""Record a successful call on *key*."""
with self._lock:
health = self._health.get(key)
if health:
health.consecutive_errors = 0
health.total_successes += 1
def get_stats(self) -> dict[str, dict]:
"""Return health stats keyed by the last 6 chars of each key."""
with self._lock:
now = time.monotonic()
return {
f"...{k[-6:]}": {
"healthy": self._health[k].rate_limited_until <= now,
"requests": self._health[k].total_requests,
"successes": self._health[k].total_successes,
"consecutive_errors": self._health[k].consecutive_errors,
}
for k in self._keys
}
+293 -31
View File
@@ -7,6 +7,8 @@ Groq, and local models.
See: https://docs.litellm.ai/docs/providers
"""
from __future__ import annotations
import ast
import asyncio
import hashlib
@@ -18,7 +20,10 @@ import time
from collections.abc import AsyncIterator
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from framework.llm.key_pool import KeyPool
try:
import litellm
@@ -33,6 +38,10 @@ from framework.llm.stream_events import StreamEvent
logger = logging.getLogger(__name__)
logging.getLogger("openai._base_client").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
def _patch_litellm_anthropic_oauth() -> None:
"""Patch litellm's Anthropic header construction to fix OAuth token handling.
@@ -272,6 +281,10 @@ OPENROUTER_TOOL_COMPAT_CACHE_TTL_SECONDS = 3600
# OpenRouter routing can change over time, so tool-compat caching must expire.
OPENROUTER_TOOL_COMPAT_MODEL_CACHE: dict[str, float] = {}
# Transient stream errors (network blips, timeouts) use a separate cap
# from rate-limit retries — 3 retries is sufficient for connection failures.
STREAM_TRANSIENT_MAX_RETRIES = 3
# Directory for dumping failed requests
FAILED_REQUESTS_DIR = Path.home() / ".hive" / "failed_requests"
@@ -338,34 +351,38 @@ def _dump_failed_request(
attempt: int,
) -> str:
"""Dump failed request to a file for debugging. Returns the file path."""
FAILED_REQUESTS_DIR.mkdir(parents=True, exist_ok=True)
try:
FAILED_REQUESTS_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
filename = f"{error_type}_{model.replace('/', '_')}_{timestamp}.json"
filepath = FAILED_REQUESTS_DIR / filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
filename = f"{error_type}_{model.replace('/', '_')}_{timestamp}.json"
filepath = FAILED_REQUESTS_DIR / filename
# Build dump data
messages = kwargs.get("messages", [])
dump_data = {
"timestamp": datetime.now().isoformat(),
"model": model,
"error_type": error_type,
"attempt": attempt,
"estimated_tokens": _estimate_tokens(model, messages),
"num_messages": len(messages),
"messages": messages,
"tools": kwargs.get("tools"),
"max_tokens": kwargs.get("max_tokens"),
"temperature": kwargs.get("temperature"),
}
# Build dump data
messages = kwargs.get("messages", [])
dump_data = {
"timestamp": datetime.now().isoformat(),
"model": model,
"error_type": error_type,
"attempt": attempt,
"estimated_tokens": _estimate_tokens(model, messages),
"num_messages": len(messages),
"messages": messages,
"tools": kwargs.get("tools"),
"max_tokens": kwargs.get("max_tokens"),
"temperature": kwargs.get("temperature"),
}
with open(filepath, "w", encoding="utf-8") as f:
json.dump(dump_data, f, indent=2, default=str)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(dump_data, f, indent=2, default=str)
# Prune old dumps to prevent unbounded disk growth
_prune_failed_request_dumps()
# Prune old dumps to prevent unbounded disk growth
_prune_failed_request_dumps()
return str(filepath)
return str(filepath)
except OSError as e:
logger.warning(f"Failed to dump request debug log to {FAILED_REQUESTS_DIR}: {e}")
return "log_write_failed"
def _compute_retry_delay(
@@ -458,6 +475,59 @@ def _is_stream_transient_error(exc: BaseException) -> bool:
return isinstance(exc, transient_types)
def _extract_text_tool_calls(
text: str,
) -> tuple[list, str]:
"""Extract hallucinated tool calls from ``<tool_code>`` blocks in LLM text.
Some models (notably Gemini) emit tool invocations as text instead of using
the structured function-calling API. This function parses those blocks and
returns ``(tool_call_events, cleaned_text)`` where *cleaned_text* has the
``<tool_code>`` blocks removed.
Expected format::
<tool_code>
{
"tool_name": { ...args }
}
</tool_code>
"""
from framework.llm.stream_events import ToolCallEvent
pattern = re.compile(r"<tool_code>\s*(.*?)\s*</tool_code>", re.DOTALL)
events: list[ToolCallEvent] = []
cleaned = text
for match in pattern.finditer(text):
raw = match.group(1).strip()
try:
payload = json.loads(raw)
except json.JSONDecodeError:
logger.warning("[_extract_text_tool_calls] failed to parse JSON: %s", raw[:200])
continue
if not isinstance(payload, dict):
continue
for tool_name, tool_args in payload.items():
key = f"{tool_name}:{json.dumps(tool_args, sort_keys=True)}"
digest = hashlib.md5(key.encode()).hexdigest()[:12]
call_id = f"synth_{digest}"
events.append(
ToolCallEvent(
tool_use_id=call_id,
tool_name=tool_name,
tool_input=tool_args if isinstance(tool_args, dict) else {},
)
)
if events:
cleaned = pattern.sub("", text).strip()
return events, cleaned
class LiteLLMProvider(LLMProvider):
"""
LiteLLM-based LLM provider for multi-provider support.
@@ -500,6 +570,7 @@ class LiteLLMProvider(LLMProvider):
model: str = "gpt-4o-mini",
api_key: str | None = None,
api_base: str | None = None,
api_keys: list[str] | None = None,
**kwargs: Any,
):
"""
@@ -512,6 +583,9 @@ class LiteLLMProvider(LLMProvider):
look for the appropriate env var (OPENAI_API_KEY,
ANTHROPIC_API_KEY, etc.)
api_base: Custom API base URL (for proxies or local deployments)
api_keys: Optional list of API keys for key-pool rotation. When
provided with 2+ keys, a :class:`KeyPool` is created and
keys are rotated on rate-limit errors.
**kwargs: Additional arguments passed to litellm.completion()
"""
# Kimi For Coding exposes an Anthropic-compatible endpoint at
@@ -533,11 +607,24 @@ class LiteLLMProvider(LLMProvider):
if api_base and api_base.rstrip("/").endswith("/v1"):
api_base = api_base.rstrip("/")[:-3]
self.model = model
self.api_key = api_key
# Key pool: when multiple keys are provided, enable rotation.
self._key_pool: KeyPool | None = None
if api_keys and len(api_keys) > 1:
from framework.llm.key_pool import KeyPool
self._key_pool = KeyPool(api_keys)
self.api_key = api_keys[0] # default for OAuth detection below
logger.info(
"[litellm] Key pool enabled with %d keys for model %s",
len(api_keys),
model,
)
else:
self.api_key = api_key or (api_keys[0] if api_keys else None)
self.api_base = api_base or self._default_api_base_for_model(_original_model)
self.extra_kwargs = kwargs
# Detect Claude Code OAuth subscription by checking the api_key prefix.
self._claude_code_oauth = bool(api_key and api_key.startswith("sk-ant-oat"))
self._claude_code_oauth = bool(self.api_key and self.api_key.startswith("sk-ant-oat"))
if self._claude_code_oauth:
# Anthropic requires a specific User-Agent for OAuth requests.
eh = self.extra_kwargs.setdefault("extra_headers", {})
@@ -555,6 +642,38 @@ class LiteLLMProvider(LLMProvider):
"LiteLLM is not installed. Please install it with: uv pip install litellm"
)
def reconfigure(
self, model: str, api_key: str | None = None, api_base: str | None = None
) -> None:
"""Hot-swap the model, API key, and/or base URL on this provider instance.
Since the same LiteLLMProvider object is shared by reference across the
session, queen runner, agent runtime, and execution streams, mutating
these attributes in-place propagates to all callers on the next LLM call.
"""
_original_model = model
if _is_ollama_model(model):
model = _ensure_ollama_chat_prefix(model)
elif model.lower().startswith("kimi/"):
model = "anthropic/" + model[len("kimi/") :]
if api_base and api_base.rstrip("/").endswith("/v1"):
api_base = api_base.rstrip("/")[:-3]
elif model.lower().startswith("hive/"):
model = "anthropic/" + model[len("hive/") :]
if api_base and api_base.rstrip("/").endswith("/v1"):
api_base = api_base.rstrip("/")[:-3]
self.model = model
self.api_key = api_key
self.api_base = api_base or self._default_api_base_for_model(_original_model)
self._claude_code_oauth = bool(api_key and api_key.startswith("sk-ant-oat"))
if self._claude_code_oauth:
eh = self.extra_kwargs.setdefault("extra_headers", {})
eh.setdefault("user-agent", CLAUDE_CODE_USER_AGENT)
self._codex_backend = bool(
self.api_base and "chatgpt.com/backend-api/codex" in self.api_base
)
self._antigravity = bool(self.api_base and "localhost:8069" in self.api_base)
# Note: The Codex ChatGPT backend is a Responses API endpoint at
# chatgpt.com/backend-api/codex/responses. LiteLLM's model registry
# correctly marks codex models with mode="responses", so we do NOT
@@ -578,10 +697,20 @@ class LiteLLMProvider(LLMProvider):
def _completion_with_rate_limit_retry(
self, max_retries: int | None = None, **kwargs: Any
) -> Any:
"""Call litellm.completion with retry on 429 rate limit errors and empty responses."""
"""Call litellm.completion with retry on 429 rate limit errors and empty responses.
When a :class:`KeyPool` is configured, rate-limited keys are rotated
automatically so the next attempt uses a different key -- no sleep
needed between attempts.
"""
model = kwargs.get("model", self.model)
retries = max_retries if max_retries is not None else RATE_LIMIT_MAX_RETRIES
for attempt in range(retries + 1):
# Rotate key from pool when available.
current_key: str | None = None
if self._key_pool:
current_key = self._key_pool.get_key()
kwargs["api_key"] = current_key
try:
response = litellm.completion(**kwargs) # type: ignore[union-attr]
@@ -656,8 +785,22 @@ class LiteLLMProvider(LLMProvider):
time.sleep(wait)
continue
if self._key_pool and current_key:
self._key_pool.mark_success(current_key)
return response
except RateLimitError as e:
# Key pool: mark the offending key and rotate immediately.
if self._key_pool and current_key:
self._key_pool.mark_rate_limited(current_key, retry_after=60.0)
# When we have other healthy keys, skip the sleep -- the
# next iteration will pick a different key automatically.
if attempt < retries:
logger.info(
"[retry] Key pool rotating away from ...%s on 429",
current_key[-6:],
)
continue
# Dump full request to file for debugging
messages = kwargs.get("messages", [])
token_count, token_method = _estimate_tokens(model, messages)
@@ -670,7 +813,7 @@ class LiteLLMProvider(LLMProvider):
if attempt == retries:
logger.error(
f"[retry] GAVE UP on {model} after {retries + 1} "
f"attempts rate limit error: {e!s}. "
f"attempts -- rate limit error: {e!s}. "
f"~{token_count} tokens ({token_method}). "
f"Full request dumped to: {dump_path}"
)
@@ -789,10 +932,16 @@ class LiteLLMProvider(LLMProvider):
"""Async version of _completion_with_rate_limit_retry.
Uses litellm.acompletion and asyncio.sleep instead of blocking calls.
When a :class:`KeyPool` is configured, rate-limited keys are rotated.
"""
model = kwargs.get("model", self.model)
retries = max_retries if max_retries is not None else RATE_LIMIT_MAX_RETRIES
for attempt in range(retries + 1):
# Rotate key from pool when available.
current_key: str | None = None
if self._key_pool:
current_key = self._key_pool.get_key()
kwargs["api_key"] = current_key
try:
response = await litellm.acompletion(**kwargs) # type: ignore[union-attr]
@@ -861,8 +1010,20 @@ class LiteLLMProvider(LLMProvider):
await asyncio.sleep(wait)
continue
if self._key_pool and current_key:
self._key_pool.mark_success(current_key)
return response
except RateLimitError as e:
# Key pool: mark the offending key and rotate immediately.
if self._key_pool and current_key:
self._key_pool.mark_rate_limited(current_key, retry_after=60.0)
if attempt < retries:
logger.info(
"[async-retry] Key pool rotating away from ...%s on 429",
current_key[-6:],
)
continue
messages = kwargs.get("messages", [])
token_count, token_method = _estimate_tokens(model, messages)
dump_path = _dump_failed_request(
@@ -874,7 +1035,7 @@ class LiteLLMProvider(LLMProvider):
if attempt == retries:
logger.error(
f"[async-retry] GAVE UP on {model} after {retries + 1} "
f"attempts rate limit error: {e!s}. "
f"attempts -- rate limit error: {e!s}. "
f"~{token_count} tokens ({token_method}). "
f"Full request dumped to: {dump_path}"
)
@@ -1608,6 +1769,40 @@ class LiteLLMProvider(LLMProvider):
full_messages.append(sys_msg)
full_messages.extend(messages)
if logger.isEnabledFor(logging.DEBUG) and full_messages:
import json as _json
from pathlib import Path as _Path
from datetime import datetime as _dt
_debug_dir = _Path.home() / ".hive" / "debug_logs"
_debug_dir.mkdir(parents=True, exist_ok=True)
_ts = _dt.now().strftime("%Y%m%d_%H%M%S_%f")
_dump_file = _debug_dir / f"llm_request_{_ts}.json"
_summary = []
for _mi, _m in enumerate(full_messages):
_role = _m.get("role", "?")
_c = _m.get("content")
_tc = _m.get("tool_calls")
_tcid = _m.get("tool_call_id")
_summary.append(
{
"idx": _mi,
"role": _role,
"content_length": len(str(_c)) if _c else 0,
"content_preview": str(_c)[:200] if _c else repr(_c),
"has_tool_calls": bool(_tc),
"tool_call_count": len(_tc) if _tc else 0,
"tool_call_id": _tcid,
}
)
try:
_dump_file.write_text(
_json.dumps(_summary, indent=2, ensure_ascii=False), encoding="utf-8"
)
logger.debug("[LLM-MSG] %d messages dumped to %s", len(full_messages), _dump_file)
except Exception:
pass
# Codex Responses API requires an `instructions` field (system prompt).
# Inject a minimal one when callers don't provide a system message.
if self._codex_backend and not any(m["role"] == "system" for m in full_messages):
@@ -1751,6 +1946,10 @@ class LiteLLMProvider(LLMProvider):
# --- Finish ---
if choice.finish_reason:
# Kimi's 'pause_turn' means the model emitted tool
# calls and expects results — equivalent to 'tool_calls'.
if choice.finish_reason == "pause_turn":
choice.finish_reason = "tool_calls" if tool_calls_acc else "stop"
stream_finish_reason = choice.finish_reason
for _idx, tc_data in sorted(tool_calls_acc.items()):
parsed_args = self._parse_tool_call_arguments(
@@ -1918,6 +2117,39 @@ class LiteLLMProvider(LLMProvider):
f"(last_role={last_role}). Returning empty result."
)
# Gemini sometimes outputs tool calls as text in
# <tool_code>{"name": {...args}}</tool_code> blocks
# instead of using the function-calling API. Extract
# these as real ToolCallEvents and strip them from the
# text so the rest of the system treats them normally.
if accumulated_text and "<tool_code>" in accumulated_text:
extracted, cleaned = _extract_text_tool_calls(accumulated_text)
if extracted:
tool_names = [tc.tool_name for tc in extracted]
logger.info(
"[stream] Model emitted %d tool call(s) as <tool_code> text "
"instead of structured function calls; converting to "
"synthetic ToolCallEvents: %s",
len(extracted),
tool_names,
)
accumulated_text = cleaned
# Emit a corrected TextDeltaEvent so the caller's
# accumulated_text is overwritten with the cleaned text.
yield TextDeltaEvent(content="", snapshot=cleaned)
# Insert synthetic ToolCallEvents before FinishEvent.
finish_idx = next(
(i for i, ev in enumerate(tail_events) if isinstance(ev, FinishEvent)),
len(tail_events),
)
for tc_ev in reversed(extracted):
tail_events.insert(finish_idx, tc_ev)
# Update TextEndEvent if present.
for _i, _ev in enumerate(tail_events):
if isinstance(_ev, TextEndEvent):
tail_events[_i] = TextEndEvent(full_text=cleaned)
break
# Success (or empty after exhausted retries) — flush events.
for event in tail_events:
yield event
@@ -1937,6 +2169,36 @@ class LiteLLMProvider(LLMProvider):
return
except Exception as e:
# Some providers return non-standard finish_reason values
# (e.g., kimi-k2.5 sends 'pause_turn') that LiteLLM's
# internal stream_chunk_builder rejects via Pydantic
# validation. If we already accumulated content and built
# tail_events before the error, the stream was successful —
# yield what we have instead of discarding it.
if (accumulated_text or tool_calls_acc) and tail_events:
# LiteLLM may wrap the original ValidationError in an
# APIError with a different message. Check the full
# exception chain (str(e) + str(__cause__)).
_err_chain = f"{e} {e.__cause__}" if e.__cause__ else str(e)
_is_finish_reason_err = (
"finish_reason" in _err_chain and "validation error" in _err_chain.lower()
) or (
# Fallback: the APIError wrapper message for chunk-building failures
"building chunks" in str(e).lower() and (accumulated_text or tool_calls_acc)
)
if _is_finish_reason_err:
logger.warning(
"[stream] %s: LiteLLM finish_reason validation "
"error (non-standard provider value). "
"Content was streamed successfully — "
"using accumulated result. Error: %s",
self.model,
e,
)
for event in tail_events:
yield event
return
if self._should_use_openrouter_tool_compat(e, tools):
_remember_openrouter_tool_compat_model(self.model)
async for event in self._stream_via_openrouter_tool_compat(
@@ -1947,13 +2209,13 @@ class LiteLLMProvider(LLMProvider):
):
yield event
return
if _is_stream_transient_error(e) and attempt < RATE_LIMIT_MAX_RETRIES:
if _is_stream_transient_error(e) and attempt < STREAM_TRANSIENT_MAX_RETRIES:
wait = _compute_retry_delay(attempt, exception=e)
logger.warning(
f"[stream-retry] {self.model} transient error "
f"({type(e).__name__}): {e!s}. "
f"Retrying in {wait:.1f}s "
f"(attempt {attempt + 1}/{RATE_LIMIT_MAX_RETRIES})"
f"(attempt {attempt + 1}/{STREAM_TRANSIENT_MAX_RETRIES})"
)
await asyncio.sleep(wait)
continue
+4
View File
@@ -0,0 +1,4 @@
"""Loader layer -- agent loading from disk (JSON config, MCP, credentials)."""
from framework.loader.agent_loader import AgentLoader # noqa: F401
from framework.loader.tool_registry import ToolRegistry # noqa: F401
File diff suppressed because it is too large Load Diff
+755
View File
@@ -0,0 +1,755 @@
"""CLI commands for Hive — queens, colonies, sessions.
The new architecture has no exported agents, no graph execution.
Everything runs through the AgentLoop driven by SessionManager.
Commands:
serve Start the HTTP API server (the runtime hub)
open Start the server and open the dashboard
queen Manage queen profiles (list, show, sessions)
colony Manage colonies (list, info, delete)
session Manage live + cold sessions (list, stop)
chat Send a message to a live queen via the HTTP API
"""
from __future__ import annotations
import argparse
import asyncio
import json
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
from urllib import error as urlerror, parse as urlparse, request as urlrequest
# ---------------------------------------------------------------------------
# Public registration
# ---------------------------------------------------------------------------
def register_commands(subparsers: argparse._SubParsersAction) -> None:
"""Register all runner commands with the main CLI parser."""
_register_serve(subparsers)
_register_open(subparsers)
_register_queen(subparsers)
_register_colony(subparsers)
_register_session(subparsers)
_register_chat(subparsers)
# ---------------------------------------------------------------------------
# serve / open
# ---------------------------------------------------------------------------
def _register_serve(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser(
"serve",
help="Start the HTTP API server",
description="Start the aiohttp server exposing REST + SSE for queens, colonies, and sessions.",
)
p.add_argument("--host", type=str, default="127.0.0.1", help="Host to bind (default: 127.0.0.1)")
p.add_argument("--port", "-p", type=int, default=8787, help="Port to listen on (default: 8787)")
p.add_argument(
"--colony",
"-c",
type=str,
action="append",
default=[],
help="Colony path or name to preload (repeatable)",
)
p.add_argument("--model", "-m", type=str, default=None, help="LLM model for preloaded colonies")
p.add_argument("--open", action="store_true", help="Open dashboard in browser after start")
p.add_argument("--verbose", "-v", action="store_true", help="Enable INFO log level")
p.add_argument("--debug", action="store_true", help="Enable DEBUG log level")
p.set_defaults(func=cmd_serve)
def _register_open(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser(
"open",
help="Start the server and open the dashboard",
description="Shortcut for 'hive serve --open'.",
)
p.add_argument("--host", type=str, default="127.0.0.1")
p.add_argument("--port", "-p", type=int, default=8787)
p.add_argument("--colony", "-c", type=str, action="append", default=[])
p.add_argument("--model", "-m", type=str, default=None)
p.add_argument("--verbose", "-v", action="store_true")
p.add_argument("--debug", action="store_true")
p.set_defaults(func=cmd_open)
def cmd_serve(args: argparse.Namespace) -> int:
"""Start the HTTP API server (the runtime hub)."""
from aiohttp import web
_build_frontend()
from framework.observability import configure_logging
from framework.server.app import create_app
if getattr(args, "debug", False):
configure_logging(level="DEBUG")
elif getattr(args, "verbose", False):
configure_logging(level="INFO")
else:
configure_logging(level="WARNING")
model = getattr(args, "model", None)
app = create_app(model=model)
async def run_server() -> None:
manager = app["manager"]
# Preload colonies specified via --colony
for colony_arg in getattr(args, "colony", []) or []:
colony_path = _resolve_colony_path(colony_arg)
if colony_path is None:
print(f"Colony not found: {colony_arg}")
continue
try:
session = await manager.create_session_with_worker_colony(
str(colony_path), model=model
)
info = session.worker_info
name = info.name if info else session.colony_id
print(f"Loaded colony: {session.colony_id} ({name}) → session {session.id}")
except Exception as e: # noqa: BLE001
print(f"Error loading colony {colony_arg}: {e}")
runner = web.AppRunner(app, access_log=None)
await runner.setup()
site = web.TCPSite(runner, args.host, args.port)
await site.start()
dashboard_url = f"http://{args.host}:{args.port}"
has_frontend = _frontend_dist_exists()
live_count = sum(1 for s in manager.list_sessions() if s.colony_runtime is not None)
queen_only = sum(1 for s in manager.list_sessions() if s.colony_runtime is None)
print()
print(f"Hive API server running on {dashboard_url}")
if has_frontend:
print(f"Dashboard: {dashboard_url}")
print(f"Health: {dashboard_url}/api/health")
print(f"Sessions: {live_count} colony, {queen_only} queen-only")
print()
print("Press Ctrl+C to stop")
if getattr(args, "open", False) and has_frontend:
_open_browser(dashboard_url)
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
finally:
await manager.shutdown_all()
await runner.cleanup()
try:
asyncio.run(run_server())
except KeyboardInterrupt:
print("\nServer stopped.")
return 0
def cmd_open(args: argparse.Namespace) -> int:
"""Start the HTTP server and open the dashboard in the browser."""
_ping_hive_gateway_availability("hive-open")
args.open = True
return cmd_serve(args)
# ---------------------------------------------------------------------------
# queen
# ---------------------------------------------------------------------------
def _register_queen(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser(
"queen",
help="Manage queen profiles",
description="List, inspect, and explore queen identities.",
)
sub = p.add_subparsers(dest="subcommand", required=True)
list_p = sub.add_parser("list", help="List all queen profiles")
list_p.add_argument("--json", action="store_true", help="Output as JSON")
list_p.set_defaults(func=cmd_queen_list)
show_p = sub.add_parser("show", help="Show a queen profile")
show_p.add_argument("queen_id", type=str, help="Queen identity (e.g. queen_technology)")
show_p.add_argument("--json", action="store_true", help="Output as JSON")
show_p.set_defaults(func=cmd_queen_show)
sess_p = sub.add_parser("sessions", help="List sessions belonging to a queen")
sess_p.add_argument("queen_id", type=str, help="Queen identity")
sess_p.add_argument("--json", action="store_true")
sess_p.set_defaults(func=cmd_queen_sessions)
def cmd_queen_list(args: argparse.Namespace) -> int:
from framework.agents.queen.queen_profiles import ensure_default_queens, list_queens
ensure_default_queens()
queens = list_queens()
if args.json:
print(json.dumps(queens, indent=2))
return 0
if not queens:
print("No queen profiles found.")
return 0
print(f"{'ID':<32} {'NAME':<24} TITLE")
print("-" * 80)
for q in queens:
print(f"{q['id']:<32} {q['name']:<24} {q['title']}")
return 0
def cmd_queen_show(args: argparse.Namespace) -> int:
from framework.agents.queen.queen_profiles import load_queen_profile
try:
profile = load_queen_profile(args.queen_id)
except FileNotFoundError as e:
print(f"Error: {e}")
return 1
if args.json:
print(json.dumps(profile, indent=2))
return 0
print(f"Queen ID: {args.queen_id}")
print(f"Name: {profile.get('name', '')}")
print(f"Title: {profile.get('title', '')}")
desc = profile.get("description") or profile.get("core_traits") or ""
if isinstance(desc, list):
desc = ", ".join(desc)
if desc:
print(f"Traits: {desc}")
skills = profile.get("skills") or []
if skills:
print(f"Skills: {', '.join(skills) if isinstance(skills, list) else skills}")
return 0
def cmd_queen_sessions(args: argparse.Namespace) -> int:
from framework.config import QUEENS_DIR
queen_dir = QUEENS_DIR / args.queen_id / "sessions"
if not queen_dir.is_dir():
print(f"No sessions for queen '{args.queen_id}'")
return 0
rows: list[dict[str, Any]] = []
for session_dir in sorted(queen_dir.iterdir()):
if not session_dir.is_dir():
continue
meta_path = session_dir / "meta.json"
meta: dict = {}
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
meta = {}
rows.append({
"session_id": session_dir.name,
"phase": meta.get("phase", "?"),
"agent_path": meta.get("agent_path", ""),
"colony_fork": bool(meta.get("colony_fork")),
})
if args.json:
print(json.dumps(rows, indent=2))
return 0
if not rows:
print(f"No sessions for queen '{args.queen_id}'")
return 0
print(f"{'SESSION':<40} {'PHASE':<10} {'COLONY':<20} FLAGS")
print("-" * 90)
for r in rows:
flags = "fork" if r["colony_fork"] else ""
colony = Path(r["agent_path"]).name if r["agent_path"] else ""
print(f"{r['session_id']:<40} {r['phase']:<10} {colony:<20} {flags}")
return 0
# ---------------------------------------------------------------------------
# colony
# ---------------------------------------------------------------------------
def _register_colony(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser(
"colony",
help="Manage colonies",
description="List, inspect, and delete colonies on disk.",
)
sub = p.add_subparsers(dest="subcommand", required=True)
list_p = sub.add_parser("list", help="List all colonies")
list_p.add_argument("--json", action="store_true")
list_p.set_defaults(func=cmd_colony_list)
info_p = sub.add_parser("info", help="Show colony details")
info_p.add_argument("name", type=str, help="Colony name or path")
info_p.add_argument("--json", action="store_true")
info_p.set_defaults(func=cmd_colony_info)
del_p = sub.add_parser("delete", help="Delete a colony from disk")
del_p.add_argument("name", type=str, help="Colony name")
del_p.add_argument(
"--purge-storage",
action="store_true",
help="Also delete worker storage at ~/.hive/agents/{name}/",
)
del_p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
del_p.set_defaults(func=cmd_colony_delete)
def cmd_colony_list(args: argparse.Namespace) -> int:
from framework.config import COLONIES_DIR
if not COLONIES_DIR.is_dir():
if args.json:
print("[]")
else:
print("No colonies found.")
return 0
rows: list[dict[str, Any]] = []
for path in sorted(COLONIES_DIR.iterdir()):
if not path.is_dir():
continue
meta_path = path / "metadata.json"
meta: dict = {}
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
meta = {}
worker_count = sum(
1
for f in path.iterdir()
if f.is_file() and f.suffix == ".json" and f.stem not in _RESERVED_JSON_STEMS
)
rows.append({
"name": path.name,
"queen_name": meta.get("queen_name", ""),
"queen_session_id": meta.get("queen_session_id", ""),
"workers": worker_count,
"created_at": meta.get("created_at", ""),
"path": str(path),
})
if args.json:
print(json.dumps(rows, indent=2))
return 0
if not rows:
print("No colonies found.")
return 0
print(f"{'NAME':<24} {'QUEEN':<28} {'WORKERS':<8} CREATED")
print("-" * 90)
for r in rows:
print(
f"{r['name']:<24} {r['queen_name']:<28} {r['workers']:<8} {r['created_at'][:19]}"
)
return 0
def cmd_colony_info(args: argparse.Namespace) -> int:
colony_path = _resolve_colony_path(args.name)
if colony_path is None:
print(f"Colony not found: {args.name}")
return 1
meta_path = colony_path / "metadata.json"
metadata: dict = {}
if meta_path.exists():
try:
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
pass
workers: dict[str, dict] = {}
for f in sorted(colony_path.iterdir()):
if not (f.is_file() and f.suffix == ".json"):
continue
if f.stem in _RESERVED_JSON_STEMS:
continue
try:
data = json.loads(f.read_text(encoding="utf-8"))
if isinstance(data, dict):
workers[f.stem] = {
"name": data.get("name", f.stem),
"description": data.get("description", ""),
"tools": len(data.get("tools", [])),
"goal": data.get("goal", {}).get("description", ""),
"spawned_from": data.get("spawned_from", ""),
}
except Exception:
pass
if args.json:
print(json.dumps({"path": str(colony_path), "metadata": metadata, "workers": workers}, indent=2))
return 0
print(f"Colony: {colony_path.name}")
print(f"Path: {colony_path}")
print(f"Queen: {metadata.get('queen_name', '?')}")
print(f"Queen Session: {metadata.get('queen_session_id', '?')}")
print(f"Source Session: {metadata.get('source_session_id', '?')}")
print(f"Created: {metadata.get('created_at', '?')}")
print()
print(f"Workers ({len(workers)}):")
for wname, w in workers.items():
print(f"{wname}")
if w["goal"]:
print(f" goal: {w['goal'][:80]}")
print(f" tools: {w['tools']}")
if w["spawned_from"]:
print(f" from: {w['spawned_from']}")
return 0
def cmd_colony_delete(args: argparse.Namespace) -> int:
from framework.config import COLONIES_DIR, HIVE_HOME
colony_path = COLONIES_DIR / args.name
if not colony_path.is_dir():
print(f"Colony not found: {args.name}")
return 1
storage_path = HIVE_HOME / "agents" / args.name
purge_storage = args.purge_storage and storage_path.is_dir()
if not args.yes:
print(f"This will permanently delete: {colony_path}")
if purge_storage:
print(f"And worker storage at: {storage_path}")
confirm = input("Type the colony name to confirm: ").strip()
if confirm != args.name:
print("Cancelled.")
return 1
shutil.rmtree(colony_path)
print(f"Deleted {colony_path}")
if purge_storage:
shutil.rmtree(storage_path)
print(f"Deleted {storage_path}")
return 0
# ---------------------------------------------------------------------------
# session
# ---------------------------------------------------------------------------
def _register_session(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser(
"session",
help="Manage sessions",
description="List live and cold sessions, stop running sessions.",
)
sub = p.add_subparsers(dest="subcommand", required=True)
list_p = sub.add_parser("list", help="List sessions")
list_p.add_argument("--cold", action="store_true", help="Include cold (on-disk) sessions")
list_p.add_argument("--server", default="http://127.0.0.1:8787", help="Hive server URL")
list_p.add_argument("--json", action="store_true")
list_p.set_defaults(func=cmd_session_list)
stop_p = sub.add_parser("stop", help="Stop a live session")
stop_p.add_argument("session_id", type=str, help="Session ID to stop")
stop_p.add_argument("--server", default="http://127.0.0.1:8787")
stop_p.set_defaults(func=cmd_session_stop)
def cmd_session_list(args: argparse.Namespace) -> int:
if args.cold:
# Read directly from disk -- works without server
from framework.server.session_manager import SessionManager
rows = SessionManager.list_cold_sessions()
else:
# Hit the server's live session endpoint
try:
data = _http_get(f"{args.server}/api/sessions")
except Exception as e: # noqa: BLE001
print(f"Could not reach server at {args.server}: {e}")
print("Tip: pass --cold to read on-disk sessions, or start 'hive serve' first.")
return 1
rows = data.get("sessions", [])
if args.json:
print(json.dumps(rows, indent=2))
return 0
if not rows:
print("No sessions.")
return 0
print(f"{'SESSION':<40} {'COLONY':<20} {'PHASE':<12} WORKER")
print("-" * 90)
for r in rows:
sid = r.get("session_id", "?")
colony = r.get("colony_name") or r.get("colony_id") or ""
phase = r.get("queen_phase", "?")
has_worker = "yes" if r.get("has_worker") else "no"
print(f"{sid:<40} {colony:<20} {phase:<12} {has_worker}")
return 0
def cmd_session_stop(args: argparse.Namespace) -> int:
try:
data = _http_delete(f"{args.server}/api/sessions/{args.session_id}")
except Exception as e: # noqa: BLE001
print(f"Could not reach server at {args.server}: {e}")
return 1
if data.get("stopped"):
print(f"Stopped session {args.session_id}")
return 0
print(f"Failed to stop session: {data}")
return 1
# ---------------------------------------------------------------------------
# chat
# ---------------------------------------------------------------------------
def _register_chat(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser(
"chat",
help="Send a message to a live queen session",
description="POST a chat message to a running session via the HTTP API.",
)
p.add_argument("session_id", type=str, help="Session ID")
p.add_argument("message", type=str, help="Message text")
p.add_argument("--server", default="http://127.0.0.1:8787", help="Hive server URL")
p.set_defaults(func=cmd_chat)
def cmd_chat(args: argparse.Namespace) -> int:
try:
data = _http_post(
f"{args.server}/api/sessions/{args.session_id}/chat",
{"message": args.message},
)
except Exception as e: # noqa: BLE001
print(f"Could not reach server at {args.server}: {e}")
return 1
if "error" in data:
print(f"Error: {data['error']}")
return 1
print(f"Sent. Tail the SSE stream at {args.server}/api/sessions/{args.session_id}/events")
return 0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# JSON files inside ~/.hive/colonies/{name}/ that are NOT worker configs.
_RESERVED_JSON_STEMS = {"agent", "flowchart", "triggers", "configuration", "metadata"}
def _resolve_colony_path(name_or_path: str) -> Path | None:
"""Resolve a colony argument to its on-disk Path.
Accepts either an absolute/relative path to a colony directory or
a bare colony name (looked up under ~/.hive/colonies/{name}/).
"""
from framework.config import COLONIES_DIR
candidate = Path(name_or_path).expanduser()
if candidate.is_dir():
return candidate
by_name = COLONIES_DIR / name_or_path
if by_name.is_dir():
return by_name
return None
def _http_get(url: str, timeout: float = 10.0) -> dict:
req = urlrequest.Request(url, method="GET")
with urlrequest.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def _http_post(url: str, body: dict, timeout: float = 30.0) -> dict:
data = json.dumps(body).encode("utf-8")
req = urlrequest.Request(
url, data=data, method="POST", headers={"Content-Type": "application/json"}
)
with urlrequest.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def _http_delete(url: str, timeout: float = 10.0) -> dict:
req = urlrequest.Request(url, method="DELETE")
with urlrequest.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def _frontend_dist_exists() -> bool:
candidates = [Path("frontend/dist"), Path("core/frontend/dist")]
return any((c / "index.html").exists() for c in candidates if c.is_dir())
def _find_chrome_bin() -> str | None:
"""Return the path to a Chrome/Chromium binary, or None if not found."""
for candidate in (
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"microsoft-edge",
"microsoft-edge-stable",
):
if shutil.which(candidate):
return candidate
mac_paths = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
Path.home() / "Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
]
for p in mac_paths:
if Path(p).exists():
return str(p)
return None
def _open_browser(url: str) -> None:
"""Open URL in the browser (best-effort, non-blocking)."""
chrome = _find_chrome_bin()
try:
if chrome:
subprocess.Popen(
[chrome, url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
except Exception:
pass
try:
if sys.platform == "darwin":
subprocess.Popen(
["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
elif sys.platform == "win32":
subprocess.Popen(
["cmd", "/c", "start", "", url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
elif sys.platform == "linux":
subprocess.Popen(
["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
except Exception:
pass
def _ping_hive_gateway_availability(from_source: str) -> None:
"""Best-effort reachability ping to the Hive gateway."""
base_url = "https://api.adenhq.com/v1/gateway/availability"
query = urlparse.urlencode({"from": from_source})
url = f"{base_url}?{query}"
try:
with urlrequest.urlopen(url, timeout=5) as response:
response.read()
except (urlerror.URLError, TimeoutError, ValueError):
pass
def _format_subprocess_output(output: str | bytes | None, limit: int = 2000) -> str:
if not output:
return ""
text = output.decode(errors="replace") if isinstance(output, bytes) else output
text = text.strip()
return text if len(text) <= limit else text[-limit:]
def _build_frontend() -> bool:
"""Build the frontend if source is newer than dist. Returns True if dist exists."""
candidates = [
Path("core/frontend"),
Path(__file__).resolve().parent.parent.parent / "frontend",
]
frontend_dir: Path | None = None
for c in candidates:
if (c / "package.json").is_file():
frontend_dir = c.resolve()
break
if frontend_dir is None:
return False
dist_dir = frontend_dir / "dist"
src_dir = frontend_dir / "src"
index_html = dist_dir / "index.html"
if index_html.exists() and src_dir.is_dir():
dist_mtime = index_html.stat().st_mtime
needs_build = False
for f in src_dir.rglob("*"):
if f.is_file() and f.stat().st_mtime > dist_mtime:
needs_build = True
break
if not needs_build:
return True
print("Building frontend...")
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
try:
for cache_file in frontend_dir.glob("tsconfig*.tsbuildinfo"):
cache_file.unlink(missing_ok=True)
subprocess.run(
[npm_cmd, "install", "--no-fund", "--no-audit"],
encoding="utf-8",
errors="replace",
cwd=frontend_dir,
check=True,
capture_output=True,
)
subprocess.run(
[npm_cmd, "run", "build"],
encoding="utf-8",
errors="replace",
cwd=frontend_dir,
check=True,
capture_output=True,
)
print("Frontend built.")
return True
except FileNotFoundError:
print("Node.js not found — skipping frontend build.")
return dist_dir.is_dir()
except subprocess.CalledProcessError as exc:
stdout = _format_subprocess_output(exc.stdout)
stderr = _format_subprocess_output(exc.stderr)
cmd = " ".join(exc.cmd) if isinstance(exc.cmd, (list, tuple)) else str(exc.cmd)
details = "\n".join(part for part in [stdout, stderr] if part).strip()
if details:
print(f"Frontend build failed while running {cmd}:\n{details}")
else:
print(f"Frontend build failed while running {cmd} (exit {exc.returncode}).")
return dist_dir.is_dir()
@@ -14,7 +14,7 @@ from typing import Any, Literal
import httpx
from framework.runner.mcp_errors import MCPToolNotFoundError
from framework.loader.mcp_errors import MCPToolNotFoundError
logger = logging.getLogger(__name__)
@@ -5,7 +5,7 @@ import threading
import httpx
from framework.runner.mcp_client import MCPClient, MCPServerConfig
from framework.loader.mcp_client import MCPClient, MCPServerConfig
logger = logging.getLogger(__name__)
@@ -14,9 +14,9 @@ from typing import Any, Literal
import httpx
from framework.runner.mcp_client import MCPClient, MCPServerConfig
from framework.runner.mcp_connection_manager import MCPConnectionManager
from framework.runner.mcp_errors import (
from framework.loader.mcp_client import MCPClient, MCPServerConfig
from framework.loader.mcp_connection_manager import MCPConnectionManager
from framework.loader.mcp_errors import (
MCPError,
MCPErrorCode,
MCPInstallError,
@@ -28,7 +28,7 @@ from typing import Any
def _get_registry(base_path: Path | None = None):
"""Initialize and return an MCPRegistry instance."""
from framework.runner.mcp_registry import MCPRegistry
from framework.loader.mcp_registry import MCPRegistry
registry = MCPRegistry(base_path=base_path)
registry.initialize()
@@ -11,8 +11,8 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from framework.graph.edge import GraphSpec
from framework.graph.node import NodeSpec
from framework.orchestrator.edge import GraphSpec
from framework.orchestrator.node import NodeSpec
logger = logging.getLogger(__name__)
@@ -48,7 +48,7 @@ class ToolRegistry:
# Framework-internal context keys injected into tool calls.
# Stripped from LLM-facing schemas (the LLM doesn't know these values)
# and auto-injected at call time for tools that accept them.
CONTEXT_PARAMS = frozenset({"agent_id", "data_dir"})
CONTEXT_PARAMS = frozenset({"agent_id", "data_dir", "profile"})
# Credential directory used for change detection
_CREDENTIAL_DIR = Path("~/.hive/credentials/credentials").expanduser()
@@ -262,15 +262,21 @@ class ToolRegistry:
is_error=False,
)
registry_ref = self
def executor(tool_use: ToolUse) -> ToolResult:
if tool_use.name not in self._tools:
# Check if credential files changed (lightweight dir listing).
# If new OAuth tokens appeared, restarts MCP servers to pick them up.
registry_ref.resync_mcp_servers_if_needed()
if tool_use.name not in registry_ref._tools:
return ToolResult(
tool_use_id=tool_use.id,
content=json.dumps({"error": f"Unknown tool: {tool_use.name}"}),
is_error=True,
)
registered = self._tools[tool_use.name]
registered = registry_ref._tools[tool_use.name]
try:
result = registered.executor(tool_use.input)
@@ -635,8 +641,8 @@ class ToolRegistry:
Number of tools registered from this server
"""
try:
from framework.runner.mcp_client import MCPClient, MCPServerConfig
from framework.runner.mcp_connection_manager import MCPConnectionManager
from framework.loader.mcp_client import MCPClient, MCPServerConfig
from framework.loader.mcp_connection_manager import MCPConnectionManager
# Build config object
config = MCPServerConfig(
@@ -883,7 +889,7 @@ class ToolRegistry:
"""Re-run ``mcp_registry.json`` resolution and register servers (post-resync)."""
if self._mcp_registry_agent_path is None:
return
from framework.runner.mcp_registry import MCPRegistry
from framework.loader.mcp_registry import MCPRegistry
try:
reg = MCPRegistry()
@@ -922,6 +928,11 @@ class ToolRegistry:
clients and re-loads them so the new subprocess picks up the fresh
credentials.
Note: Individual credential TTL/refresh is handled by the MCP server
process internally -- it resolves tokens from the credential store
on every tool call, not at startup. This method only handles the case
where entirely new credential files appear.
Returns True if a resync was performed, False otherwise.
"""
if not self._mcp_clients or self._mcp_config_path is None:
@@ -975,7 +986,7 @@ class ToolRegistry:
server_name = self._mcp_client_servers.get(client_id, client.config.name)
try:
if client_id in self._mcp_managed_clients:
from framework.runner.mcp_connection_manager import MCPConnectionManager
from framework.loader.mcp_connection_manager import MCPConnectionManager
MCPConnectionManager.get_instance().release(server_name)
else:
+27
View File
@@ -0,0 +1,27 @@
"""Orchestrator layer -- how agents are composed via graphs.
Lazy imports to avoid circular dependencies with graph/event_loop/*.
"""
def __getattr__(name: str):
if name in ("GraphContext",):
from framework.orchestrator.context import GraphContext
return GraphContext
if name in ("DEFAULT_MAX_TOKENS", "EdgeCondition", "EdgeSpec", "GraphSpec"):
from framework.orchestrator import edge as _e
return getattr(_e, name)
if name in ("Orchestrator", "ExecutionResult"):
from framework.orchestrator import orchestrator as _o
return getattr(_o, name)
if name in ("Constraint", "Goal", "GoalStatus", "SuccessCriterion"):
from framework.orchestrator import goal as _g
return getattr(_g, name)
if name in ("DataBuffer", "NodeContext", "NodeProtocol", "NodeResult", "NodeSpec"):
from framework.orchestrator import node as _n
return getattr(_n, name)
if name in ("NodeWorker", "Activation", "FanOutTag", "FanOutTracker",
"WorkerCompletion", "WorkerLifecycle"):
from framework.orchestrator import node_worker as _nw
return getattr(_nw, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+182
View File
@@ -0,0 +1,182 @@
"""
Client I/O gateway for graph nodes.
Provides the bridge between node code and external clients:
- ActiveNodeClientIO: for client_facing=True nodes (streams output, accepts input)
- InertNodeClientIO: for client_facing=False nodes (logs internally, redirects input)
- ClientIOGateway: factory that creates the right variant per node
"""
from __future__ import annotations
import asyncio
import logging
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from framework.host.event_bus import EventBus
logger = logging.getLogger(__name__)
class NodeClientIO(ABC):
"""Abstract base for node client I/O."""
@abstractmethod
async def emit_output(self, content: str, is_final: bool = False) -> None:
"""Emit output content. If is_final=True, signal end of stream."""
@abstractmethod
async def request_input(self, prompt: str = "", timeout: float | None = None) -> str:
"""Request input. Behavior depends on whether the node is client-facing."""
class ActiveNodeClientIO(NodeClientIO):
"""
Client I/O for client_facing=True nodes.
- emit_output() queues content and publishes CLIENT_OUTPUT_DELTA.
- request_input() publishes CLIENT_INPUT_REQUESTED, then awaits provide_input().
- output_stream() yields queued content until the final sentinel.
"""
def __init__(
self,
node_id: str,
event_bus: EventBus | None = None,
execution_id: str = "",
) -> None:
self.node_id = node_id
self._event_bus = event_bus
self._execution_id = execution_id
self._output_queue: asyncio.Queue[str | None] = asyncio.Queue()
self._output_snapshot = ""
self._input_event: asyncio.Event | None = None
self._input_result: str | None = None
async def emit_output(self, content: str, is_final: bool = False) -> None:
# Strip leading whitespace from first output chunk to avoid leading spaces
# (some LLMs like Kimi output leading whitespace before text)
if not self._output_snapshot and content:
content = content.lstrip()
if not content: # Content was all whitespace
return
self._output_snapshot += content
await self._output_queue.put(content)
if self._event_bus is not None:
await self._event_bus.emit_client_output_delta(
stream_id=self.node_id,
node_id=self.node_id,
content=content,
snapshot=self._output_snapshot,
execution_id=self._execution_id or None,
)
if is_final:
await self._output_queue.put(None)
async def request_input(self, prompt: str = "", timeout: float | None = None) -> str:
if self._input_event is not None:
raise RuntimeError("request_input already pending for this node")
self._input_event = asyncio.Event()
self._input_result = None
if self._event_bus is not None:
await self._event_bus.emit_client_input_requested(
stream_id=self.node_id,
node_id=self.node_id,
prompt=prompt,
execution_id=self._execution_id or None,
)
try:
if timeout is not None:
await asyncio.wait_for(self._input_event.wait(), timeout=timeout)
else:
await self._input_event.wait()
finally:
self._input_event = None
if self._input_result is None:
raise RuntimeError("input event was set but no input was provided")
result = self._input_result
self._input_result = None
return result
async def provide_input(self, content: str) -> None:
"""Called externally to fulfill a pending request_input()."""
if self._input_event is None:
raise RuntimeError("no pending request_input to fulfill")
self._input_result = content
self._input_event.set()
async def output_stream(self) -> AsyncIterator[str]:
"""Async iterator that yields output chunks until the final sentinel."""
while True:
chunk = await self._output_queue.get()
if chunk is None:
break
yield chunk
class InertNodeClientIO(NodeClientIO):
"""
Client I/O for client_facing=False nodes.
- emit_output() publishes NODE_INTERNAL_OUTPUT (content is not discarded).
- request_input() publishes NODE_INPUT_BLOCKED and returns a redirect string.
"""
def __init__(
self,
node_id: str,
event_bus: EventBus | None = None,
) -> None:
self.node_id = node_id
self._event_bus = event_bus
async def emit_output(self, content: str, is_final: bool = False) -> None:
if self._event_bus is not None:
await self._event_bus.emit_node_internal_output(
stream_id=self.node_id,
node_id=self.node_id,
content=content,
)
async def request_input(self, prompt: str = "", timeout: float | None = None) -> str:
if self._event_bus is not None:
await self._event_bus.emit_node_input_blocked(
stream_id=self.node_id,
node_id=self.node_id,
prompt=prompt,
)
return (
"You are an internal processing node. There is no user to interact with."
" Work with the data provided in your inputs to complete your task."
)
class ClientIOGateway:
"""Factory that creates the appropriate NodeClientIO for a node."""
def __init__(self, event_bus: EventBus | None = None) -> None:
self._event_bus = event_bus
def create_io(self, node_id: str, client_facing: bool, execution_id: str = "") -> NodeClientIO:
if client_facing:
return ActiveNodeClientIO(
node_id=node_id,
event_bus=self._event_bus,
execution_id=execution_id,
)
return InertNodeClientIO(
node_id=node_id,
event_bus=self._event_bus,
)
@@ -10,13 +10,32 @@ This module centralizes:
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any
from framework.graph.edge import GraphSpec
from framework.graph.goal import Goal
from framework.graph.node import DataBuffer, NodeContext, NodeProtocol, NodeSpec
from framework.runtime.core import Runtime
from framework.orchestrator.edge import GraphSpec
from framework.orchestrator.goal import Goal
from framework.orchestrator.node import DataBuffer, NodeContext, NodeProtocol, NodeSpec
from framework.tracker.decision_tracker import DecisionTracker
logger = logging.getLogger(__name__)
# Tool names that are ALWAYS available to every node, regardless of
# the node's explicit tool policy. These are framework essentials that
# agents need unconditionally.
_ALWAYS_AVAILABLE_TOOLS: frozenset[str] = frozenset(
{
"read_file",
"write_file",
"edit_file",
"list_directory",
"search_files",
"hashline_edit",
"set_output",
"escalate",
}
)
@dataclass
@@ -26,7 +45,7 @@ class GraphContext:
graph: GraphSpec
goal: Goal
buffer: DataBuffer
runtime: Runtime
runtime: DecisionTracker
llm: Any # LLMProvider
tools: list[Any] # list[Tool]
tool_executor: Any # Callable
@@ -67,12 +86,6 @@ class GraphContext:
# Retry tracking: worker_id → retry_count (for execution quality assessment)
retry_counts: dict[str, int] = field(default_factory=dict)
nodes_with_retries: set[str] = field(default_factory=set)
# Colony memory reflection at node handoff
colony_memory_dir: Any = None # Path | None
worker_sessions_dir: Any = None # Path | None
colony_recall_cache: dict[str, str] = field(default_factory=dict)
colony_reflect_llm: Any = None # LLMProvider for reflection
_colony_reflect_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
def build_scoped_buffer(buffer: DataBuffer, node_spec: NodeSpec) -> DataBuffer:
@@ -112,7 +125,7 @@ def build_node_accounts_prompt(
resolved = accounts_prompt
if accounts_data and tool_provider_map:
from framework.graph.prompting import build_accounts_prompt
from framework.orchestrator.prompting import build_accounts_prompt
filtered = build_accounts_prompt(
accounts_data,
@@ -131,15 +144,41 @@ def _resolve_available_tools(
tools: list[Any],
override_tools: list[Any] | None,
) -> list[Any]:
"""Select tools available to the current node."""
"""Select tools available to the current node.
Respects ``node_spec.tool_access_policy``:
- ``"explicit"`` -- only tools whose name appears in ``node_spec.tools``
PLUS framework-default tools (read_file, set_output, etc.).
If the list is empty, only defaults are given.
- ``"none"`` -- only framework-default tools (read_file, set_output, etc.).
Framework-default tools (``_ALWAYS_AVAILABLE_TOOLS``) are always included
regardless of policy agents need file I/O and output/escalate to function.
"""
if override_tools is not None:
return list(override_tools)
# Merge override with always-available, dedup by name
names = {t.name for t in override_tools}
extra = [t for t in tools if t.name in _ALWAYS_AVAILABLE_TOOLS and t.name not in names]
return list(override_tools) + extra
policy = getattr(node_spec, "tool_access_policy", "explicit")
# Always include framework-default tools
always_tools = [t for t in tools if t.name in _ALWAYS_AVAILABLE_TOOLS]
if policy == "none":
return always_tools
# "explicit" (default): declared tools + framework defaults
if not node_spec.tools:
return []
return always_tools
return [tool for tool in tools if tool.name in node_spec.tools]
declared = set(node_spec.tools)
declared_tools = [
t for t in tools if t.name in declared and t.name not in _ALWAYS_AVAILABLE_TOOLS
]
return always_tools + declared_tools
def _derive_input_data(buffer: DataBuffer, input_keys: list[str]) -> dict[str, Any]:
@@ -155,7 +194,7 @@ def _derive_input_data(buffer: DataBuffer, input_keys: list[str]) -> dict[str, A
def build_node_context(
*,
runtime: Runtime,
runtime: DecisionTracker,
node_spec: NodeSpec,
buffer: DataBuffer,
goal: Goal,
@@ -240,9 +279,6 @@ def build_node_context(
execution_id=execution_id,
run_id=run_id,
stream_id=stream_id,
node_registry=node_registry or {},
all_tools=list(all_tools or tools),
shared_node_registry=shared_node_registry or {},
dynamic_tools_provider=dynamic_tools_provider,
dynamic_prompt_provider=dynamic_prompt_provider,
dynamic_memory_provider=dynamic_memory_provider,
@@ -276,7 +312,11 @@ def build_node_context_from_graph_context(
gc = graph_context
resolved_override_tools = override_tools
if resolved_override_tools is None and gc.is_continuous and gc.cumulative_tools:
resolved_override_tools = list(gc.cumulative_tools)
if node_spec.tool_access_policy == "explicit" and node_spec.tools:
declared = set(node_spec.tools) | _ALWAYS_AVAILABLE_TOOLS
resolved_override_tools = [t for t in gc.cumulative_tools if t.name in declared]
else:
resolved_override_tools = list(gc.cumulative_tools)
resolved_inherited_conversation = inherited_conversation
if resolved_inherited_conversation is None and gc.is_continuous:
@@ -307,14 +347,13 @@ def build_node_context_from_graph_context(
accounts_data=gc.accounts_data,
tool_provider_map=gc.tool_provider_map,
fallback_to_default_accounts_prompt=fallback_to_default_accounts_prompt,
identity_prompt=identity_prompt if identity_prompt is not None else getattr(gc.graph, "identity_prompt", "") or "",
identity_prompt=identity_prompt
if identity_prompt is not None
else getattr(gc.graph, "identity_prompt", "") or "",
narrative=narrative,
execution_id=gc.execution_id,
run_id=gc.run_id,
stream_id=gc.stream_id,
node_registry=node_registry or gc.node_spec_registry,
all_tools=gc.tools,
shared_node_registry=gc.node_registry,
dynamic_tools_provider=gc.dynamic_tools_provider,
dynamic_prompt_provider=gc.dynamic_prompt_provider,
dynamic_memory_provider=gc.dynamic_memory_provider,
@@ -6,10 +6,10 @@ import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from framework.graph.conversation import _try_extract_key
from framework.agent_loop.conversation import _try_extract_key
if TYPE_CHECKING:
from framework.graph.conversation import NodeConversation
from framework.agent_loop.conversation import NodeConversation
from framework.llm.provider import LLMProvider
logger = logging.getLogger(__name__)
@@ -15,7 +15,7 @@ import logging
from dataclasses import dataclass
from typing import Any
from framework.graph.conversation import NodeConversation
from framework.agent_loop.conversation import NodeConversation
from framework.llm.provider import LLMProvider
logger = logging.getLogger(__name__)
@@ -29,7 +29,7 @@ from typing import Any
from pydantic import BaseModel, Field, model_validator
from framework.graph.safe_eval import safe_eval
from framework.orchestrator.safe_eval import safe_eval
logger = logging.getLogger(__name__)
@@ -302,6 +302,7 @@ Respond with ONLY a JSON object:
return result
class GraphSpec(BaseModel):
"""
Complete specification of an agent graph.
@@ -537,13 +538,6 @@ class GraphSpec(BaseModel):
for edge in self.get_outgoing_edges(current):
to_visit.append(edge.target)
# Also mark sub-agents as reachable (they're invoked via delegate_to_sub_agent, not edges)
for node in self.nodes:
if node.id in reachable:
sub_agents = getattr(node, "sub_agents", []) or []
for sub_agent_id in sub_agents:
reachable.add(sub_agent_id)
for node in self.nodes:
if node.id not in reachable:
# Skip if node is a pause node or entry point target
@@ -582,48 +576,4 @@ class GraphSpec(BaseModel):
else:
seen_keys[key] = node_id
# GCU nodes must only be used as subagents
gcu_node_ids = {n.id for n in self.nodes if n.node_type == "gcu"}
if gcu_node_ids:
# GCU nodes must not be entry nodes
if self.entry_node in gcu_node_ids:
errors.append(
f"GCU node '{self.entry_node}' is used as entry node. "
"GCU nodes must only be used as subagents via delegate_to_sub_agent()."
)
# GCU nodes must not be terminal nodes
for term in self.terminal_nodes:
if term in gcu_node_ids:
errors.append(
f"GCU node '{term}' is used as terminal node. "
"GCU nodes must only be used as subagents."
)
# GCU nodes must not be connected via edges
for edge in self.edges:
if edge.source in gcu_node_ids:
errors.append(
f"GCU node '{edge.source}' is used as edge source (edge '{edge.id}'). "
"GCU nodes must only be used as subagents, not connected via edges."
)
if edge.target in gcu_node_ids:
errors.append(
f"GCU node '{edge.target}' is used as edge target (edge '{edge.id}'). "
"GCU nodes must only be used as subagents, not connected via edges."
)
# GCU nodes must be referenced in at least one parent's sub_agents
referenced_subagents = set()
for node in self.nodes:
for sa_id in node.sub_agents or []:
referenced_subagents.add(sa_id)
orphaned = gcu_node_ids - referenced_subagents
for nid in orphaned:
errors.append(
f"GCU node '{nid}' is not referenced in any node's sub_agents list. "
"GCU nodes must be declared as subagents of a parent node."
)
return {"errors": errors, "warnings": warnings}
@@ -1,34 +1,14 @@
"""GCU (browser automation) node type constants.
"""Browser automation best-practices prompt.
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.
This module provides ``GCU_BROWSER_SYSTEM_PROMPT`` -- a canonical set of
browser automation guidelines that can be included in any node's system
prompt that uses browser tools from the gcu-tools MCP server.
No new ``NodeProtocol`` subclass the ``gcu`` type is purely a declarative
signal processed by the runner and executor at setup time.
Browser tools are registered via the global MCP registry (gcu-tools).
Nodes that need browser access declare ``tools: {policy: "all"}`` in their
agent.json config.
"""
# ---------------------------------------------------------------------------
# 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
@@ -114,6 +94,82 @@ After reading or extracting data from a tab, close it immediately.
Never accumulate tabs. Treat every tab you open as a resource you must free.
## Shadow DOM & Overlays
Some sites (LinkedIn messaging, etc.) render content inside closed shadow roots that are
invisible to regular DOM queries and `browser_snapshot` coordinates.
**Detecting shadow DOM**: `document.elementFromPoint(x, y)` returns a zero-height host element
(e.g. `#interop-outlet`) for the entire overlay area — this is normal, not a bug.
`document.body.innerText` and `document.querySelectorAll` return nothing for shadow content.
`browser_snapshot` CAN read shadow DOM text but cannot return coordinates.
**Querying into shadow DOM:**
```
browser_shadow_query("#interop-outlet >>> #msg-overlay >>> p")
```
Uses `>>>` to pierce shadow roots. Returns `rect` in CSS pixels and `physicalRect` ready for
`browser_click_coordinate` / `browser_hover_coordinate`.
**Getting physical rect for any element (including shadow DOM):**
```
browser_get_rect(selector="#interop-outlet >>> .msg-convo-wrapper", pierce_shadow=true)
```
**Manual JS traversal when selector is dynamic:**
```js
const shadow = document.getElementById('interop-outlet').shadowRoot;
const convo = shadow.querySelector('#ember37');
const rect = convo.querySelector('p').getBoundingClientRect();
// rect is in CSS pixels multiply by DPR for physical pixels
```
Pass this as a multi-statement script to `browser_evaluate`; it wraps automatically in an IIFE.
Use `JSON.stringify(rect)` to serialize the result.
## Coordinate System
There are THREE coordinate spaces. Using the wrong one causes clicks/hovers to land in the
wrong place.
| Space | Used by | How to get |
|---|---|---|
| Physical pixels | `browser_click_coordinate` | `browser_coords` `physical_x/y` |
| CSS pixels | `getBoundingClientRect()`, `elementFromPoint` | `browser_coords` `css_x/y` |
| Screenshot pixels | What you see in the 800px image | Raw position in screenshot |
**Converting screenshot physical**: `browser_coords(x, y)` use `physical_x/y`.
**Converting CSS physical**: multiply by `window.devicePixelRatio` (typically 1.6 on HiDPI).
**Never** pass raw `getBoundingClientRect()` values to `browser_hover_coordinate` without
multiplying by DPR first.
## Screenshots
Screenshot data is base64-encoded PNG. To view it:
```
run_command("echo '<base64_data>' | base64 -d > /tmp/screenshot.png")
```
Then use `read_file("/tmp/screenshot.png")` to view the image.
Always use `full_page=false` (default) unless you specifically need the full scrolled page.
## JavaScript Evaluation
`browser_evaluate` wraps your script in an IIFE automatically:
- Single expression (`document.title`) wrapped with `return`
- Multi-statement or contains `;`/`\n` wrapped without return (add explicit `return` yourself)
- Already an IIFE run as-is
**Avoid**: complex closures with `return` inside `for` loops Chrome CDP returns `null`.
**Use instead**: `Array.from(...).map(...).join(...)` chains, or build result objects and
`JSON.stringify()` them.
**For shadow DOM traversal with dynamic selectors**, write the full JS path:
```js
const s = document.getElementById('interop-outlet').shadowRoot;
const el = s.querySelector('.msg-convo-wrapper');
return JSON.stringify(el.getBoundingClientRect());
```
## 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.

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