Compare commits

...

450 Commits

Author SHA1 Message Date
Timothy Zhang a57d58e8d4 fix: bom safe json loading 2026-03-05 14:27:15 -08:00
Bryan @ Aden 85c204a442 Merge pull request #5403 from jackthepunished/feat/telegram-tool-expansion
feat(tools): expand Telegram tool with message management, media, and chat info operations
2026-03-05 19:48:05 +00:00
Timothy @aden 56075a25a3 Merge pull request #5884 from aden-hive/feature/hive-as-a-game
feat(micro-fix): link discord github template
2026-03-05 10:55:13 -08:00
Timothy 2b0a6779cc feat: link discord github template 2026-03-05 10:54:30 -08:00
Timothy @aden b9ddce9d41 Merge pull request #5881 from aden-hive/docs-contributor-registration---Timothy
docs: Add TimothyZhang7 to contributors list
2026-03-05 10:37:26 -08:00
Timothy @aden 0c85406bc2 Add TimothyZhang7 to contributors list 2026-03-05 10:36:19 -08:00
Timothy @aden 1051134594 Merge pull request #5878 from aden-hive/feature/hive-as-a-game
chore: fix repo owner
2026-03-05 10:32:20 -08:00
jackthepunished 653d24df9d fix: address review — use POST for getChat, return raw API responses
- Change get_chat client method from httpx.get+params to httpx.post+json
  to avoid URL-encoding issues with @username chat IDs
- Remove {"success": True} normalization from delete_message,
  send_chat_action, pin_message, and unpin_message MCP tools;
  return raw Telegram API response consistently
- Update corresponding test mocks and assertions to match
2026-03-05 20:40:46 +03:00
jackthepunished b687fa9e94 feat(tools): expand Telegram tool with message management, media, and chat info operations
Add 8 new operations to the Telegram Bot tool, bringing it from 2 to 10
operations. This covers message lifecycle (edit, delete, forward), media
(send photo), chat info (get chat), UX (typing indicators), and pin
management — making the tool practical for agent workflows beyond
fire-and-forget messaging.

New operations:
- telegram_edit_message: edit previously sent messages
- telegram_delete_message: delete messages
- telegram_forward_message: forward between chats
- telegram_send_photo: send photos via URL or file_id
- telegram_send_chat_action: show typing/uploading indicators
- telegram_get_chat: retrieve chat metadata
- telegram_pin_message: pin important messages
- telegram_unpin_message: unpin stale messages

Also includes input validation for chat actions, credential spec updates,
central registry wiring, and 31 new tests (52 total).

Closes #4808
2026-03-05 20:40:45 +03:00
Timothy c7f0ab0444 chore: fix repo owner 2026-03-05 09:33:02 -08:00
Timothy @aden 93bf373a5b Merge pull request #5869 from aden-hive/feature/hive-as-a-game
feat: integration bounty program with Lurkr XP, Discord roles, and automated tracking
2026-03-05 09:29:48 -08:00
Timothy 2d87042a70 fix: bad chars 2026-03-05 09:00:51 -08:00
Timothy 8a28abb7b8 fix: github actions 2026-03-05 09:00:06 -08:00
Emmanuel Nwanguma 0cdfbac5a1 docs(tools): add README for brevo, csv, runtime_logs, account_info tools (#5602)
* docs(tools): add README for brevo, csv, runtime_logs, account_info tools

- brevo_tool: Transactional email/SMS and contact management via Brevo API
- csv_tool: Read, write, query CSV files with DuckDB SQL support
- runtime_logs_tool: Query three-level runtime logging system
- account_info_tool: Query connected accounts and identities

* docs: fix runtime_logs_tool README to match implementation

- query_runtime_logs: add missing status values (degraded, in_progress, needs_attention)
- query_runtime_log_details: add missing needs_attention_only parameter
- query_runtime_log_raw: fix step_type -> step_index (int, not str)
- Fix file names: nodes.jsonl -> details.jsonl, steps.jsonl -> tool_logs.jsonl
- Fix error handling examples to match actual code

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-03-05 18:51:47 +08:00
alidevh 29a3ae471f fix(config): add logging for config parse errors (#4955)
Co-authored-by: alihassan <239741857+alidevh@users.noreply.github.com>
2026-03-05 18:22:34 +08:00
singhhnitin 9c0f56f027 Improve indirect variable expansion for provider API key detection (#5504)
Co-authored-by: Nitin Singh <nitinsingh3323@gmail.com>
2026-03-05 18:12:14 +08:00
Hundao 462e303a6e ci: skip POSIX permission tests on Windows (temporary, see #5842) (#5847)
Windows does not support POSIX file permissions, causing 4 test
failures on Windows CI. Skip these tests until the proper
ReplaceFileW fix lands.
2026-03-05 17:50:05 +08:00
Hundao a84b3c7867 fix: validate agent.json before parsing in AgentRunner.load() (#5846)
Use is_file() instead of exists() to reject directories, and check
for empty content before passing to json parser. Prevents raw
tracebacks on invalid agent.json inputs.

Fixes #5787
2026-03-05 17:23:46 +08:00
Anushka Punekar 606267d053 fix(cli): validate --output path before agent execution in cmd_run (#5838)
* fix(cli): validate --output path before agent execution in cmd_run

* style: fix indentation and formatting

---------

Co-authored-by: hundao <alchemy_wimp@hotmail.com>
2026-03-05 16:51:33 +08:00
Timothy @aden 35791ae478 Merge pull request #5834 from aden-hive/fix/quickstart-tweaks
Release / Create Release (push) Waiting to run
chore(micro-fix): tweak quickstart
2026-03-04 20:06:40 -08:00
Timothy 10f0002080 chore: tweak quickstart 2026-03-04 20:05:16 -08:00
Bryan @ Aden 60bff4107d Merge pull request #5833 from aden-hive/feat/google-scopes
micro-fix: quickstart build failing
2026-03-05 04:03:55 +00:00
bryan be11fa4b29 fix: quickstart build failing 2026-03-04 20:02:23 -08:00
Bryan @ Aden da8bc796d3 Merge pull request #5832 from aden-hive/feat/google-scopes
(micro-fix): chore: updating tool tests
2026-03-05 03:53:25 +00:00
bryan 429619379e fix: linter issues 2026-03-04 19:50:25 -08:00
bryan 0fecedbbbf chore: updating tool tests 2026-03-04 19:47:55 -08:00
Timothy @aden a2244ada75 Merge pull request #5764 from aden-hive/feat/google-scopes
Feat/google scopes
2026-03-04 19:43:30 -08:00
bryan 7608ba9290 Merge branch 'main' into feat/google-scopes 2026-03-04 19:40:46 -08:00
bryan f5f3396d5c chore: update icons of sample agents 2026-03-04 19:38:14 -08:00
bryan ed80ae80f0 feat: twitter news sample agent 2026-03-04 19:37:34 -08:00
Timothy c7a47c71f0 fix: simplify game plan 2026-03-04 19:15:27 -08:00
Timothy @aden b14b8f8c52 Merge pull request #5815 from levxn/bug/agent-sessions
Restoring session during server restart | smooth conversation picked from where left off | fix unhandled error in event routes |
2026-03-04 19:10:38 -08:00
bryan df1a83d475 feat/local-business-sample-agent 2026-03-04 19:09:02 -08:00
bryan 5b7727cfd1 fix: permanent top bar 2026-03-04 19:08:20 -08:00
Timothy 93e270dafb fix: change initial plan 2026-03-04 19:01:24 -08:00
Timothy be675dbb17 fix: restructure docs 2026-03-04 18:59:20 -08:00
Timothy 1c24848db3 feat: implement hive github repo and discord as a connected game 2026-03-04 18:52:42 -08:00
Timothy @aden 4b5ec796bc Merge pull request #5829 from aden-hive/feat/remove-old-session-status-tools
fix: remove the reference in the coder agent init
2026-03-04 17:42:34 -08:00
Richard Tang 24df4729ca fix: remove the reference in the coder agent init 2026-03-04 17:40:28 -08:00
Timothy @aden 1e6538efac Merge pull request #5828 from aden-hive/feat/remove-old-session-status-tools
Remove deprecated get_agent_session_state and get_agent_session_memory tools
2026-03-04 17:29:35 -08:00
Richard Tang f9e53f58af refactor: remove old get_agent_session_state and get_agent_session_memory tools 2026-03-04 17:23:10 -08:00
Timothy 41388efc31 fix: Windows compat — guard os.fchmod and remove deleted LLM_CREDENTIALS import
os.fchmod does not exist on Windows; guard with hasattr check.
Remove LLM_CREDENTIALS reference from test (module deleted in e1db3a4).
2026-03-04 17:22:21 -08:00
Timothy @aden fab5ce6fd0 Merge pull request #5824 from aden-hive/chore/fix-tool-tests
chore(micro-fix): fix test
2026-03-04 17:16:10 -08:00
Timothy 207d6baee5 chore: fix test 2026-03-04 16:49:39 -08:00
Timothy @aden fec72bb2b6 Merge pull request #5294 from Antiarin/feat/hashline-edit-tool
[Integration]feat: add hashline anchor-based file editing tool
2026-03-04 16:38:13 -08:00
Timothy c4c4c24c59 Merge branch 'main' into feat/hashline-edit-tool 2026-03-04 16:23:07 -08:00
bryan 917c7706ea chore: lint fix 2026-03-04 16:14:56 -08:00
bryan 8fadcd5b21 Merge branch 'main' into feat/google-scopes 2026-03-04 16:12:31 -08:00
Timothy @aden 2005ba2dca Merge pull request #5823 from aden-hive/micro-fix/lint
chore(micro-fix): lint
2026-03-04 16:11:51 -08:00
Timothy 557d5fd6e5 chore: lint 2026-03-04 16:10:35 -08:00
Timothy @aden 79d2a15f95 Merge pull request #5814 from fermano/feature/windows-filesysten
Feature/windows filesystem
2026-03-04 16:07:33 -08:00
Timothy ab32e44128 style: ruff format fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:00:13 -08:00
Bryan @ Aden 047059f85f Merge pull request #5774 from kostasuser01gr/docs/4780-roadmap-updates
docs: update roadmap to reflect completed features (refs #4780)
2026-03-04 23:59:56 +00:00
Timothy e8364f616d Merge remote-tracking branch 'origin/main' into feature/windows-filesysten 2026-03-04 15:59:49 -08:00
Bryan @ Aden 9098c9b6c6 Merge pull request #5785 from code-Miracle49/fix/remove-duplicate-execute-subagent
micro-fix: remove duplicate _execute_subagent method in EventLoopNode
2026-03-04 23:55:36 +00:00
Timothy 84fd9ebac8 style: fix E501 line-too-long lint errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:53:45 -08:00
Timothy @aden 23d5d76d56 Merge pull request #5822 from aden-hive/fix/anthropic-vendor-issue
fix: remove hardcoded Anthropic dependencies from core framework
2026-03-04 15:46:16 -08:00
Timothy b0c86588b6 chore: lint 2026-03-04 15:44:11 -08:00
Timothy 5aff1f9489 chore: lint 2026-03-04 15:43:59 -08:00
Timothy Zhang 199cb3d8cc fix: stdin conflicts 2026-03-04 14:45:02 -08:00
Fernando Mano a98a4ca0b6 feature(WindowsFilesystemSupport): #5677 - Windows File System Support and Testing -- fixing lint issues 2026-03-04 21:22:11 -03:00
Fernando Mano c4f49aadfa feature(WindowsFilesystemSupport): #5677 - Windows File System Support and Testing -- fixing lint issues 2026-03-04 21:20:33 -03:00
Fernando Mano ca5ac389cf feature(WindowsFilesystemSupport): #5677 - Windows File System Support and Testing -- fixing lint issues 2026-03-04 21:15:08 -03:00
Fernando Mano 7a658f7953 feature(WindowsFilesystemSupport): #5677 - Windows File System Support and Testing -- fixing lint issues 2026-03-04 21:05:49 -03:00
Timothy e05fc99da7 Merge branch 'main' into fix/anthropic-vendor-issue 2026-03-04 14:43:27 -08:00
Bryan @ Aden 787090667e Merge pull request #5816 from aden-hive/fix/pause-stop-worker
(micro-fix): update pause in pipeline uses stop_worker like queen
2026-03-04 21:50:23 +00:00
Antiarin 80b36b4052 fix: CRLF double-conversion in hashline edit and add large file skip reporting
- Replace joined.replace("\n", "\r\n") with re.sub(r"(?<!\r)\n", "\r\n", joined to prevent \r\n in replace op new_content from becoming \r\r\n (fixed in both hashline_edit.py and file_ops.py)
 - Track and report skipped large files in grep_search instead of silently skipping them
 - Extract HASHLINE_MAX_FILE_BYTES constant to hashline.py as single source of truth, imported by view_file, grep_search, hashline_edit, and file_ops
 - Add tests for CRLF replace op (both copies) and large file skip reporting
2026-03-05 03:12:35 +05:30
bryan 0b8ed521c0 fix:update pause in pipeline uses stop_worker like queen 2026-03-04 13:42:20 -08:00
levxn 1ec7c5545f fixing lints and formatting 2026-03-05 02:59:57 +05:30
levxn cc6b6760c3 enables resume from where it was left off 2026-03-05 02:34:23 +05:30
Levin 26aed90ab2 Merge branch 'aden-hive:main' into bug/agent-sessions 2026-03-05 02:32:56 +05:30
Timothy 1c58ccb0c1 chore: lint 2026-03-04 12:45:27 -08:00
Timothy 79b80fe817 feat: coder tools to also support hashline editing 2026-03-04 12:41:07 -08:00
Antiarin c0f3841af7 feat: add file size check in grep_search to skip large files and switch case in hashline edit
- Implemented a check to skip files larger than 10MB in the grep_search function to optimize memory usage.
2026-03-04 12:41:07 -08:00
Antiarin 2b7d9bc471 feat:Updating the docs 2026-03-04 12:41:07 -08:00
Antiarin 98dc493a39 feat: Add cross-tool hashing anchor, grep search, and all viewfile 2026-03-04 12:41:07 -08:00
Antiarin cfaa57b28d feat:Add hashing tool 2026-03-04 12:40:25 -08:00
Timothy @aden 219e603de6 Merge pull request #5813 from aden-hive/refactor/quickstart
Refactor/quickstart
2026-03-04 12:27:45 -08:00
Timothy @aden 7663a5bce8 Merge pull request #5797 from Waryjustice/fix/windows-browser-auto-open
fix: browser auto-open after quickstart does not work on Windows
2026-03-04 12:27:35 -08:00
Timothy f2841b945d chore: lint 2026-03-04 12:24:08 -08:00
bryan faff64c413 chore: agents.md update 2026-03-04 12:12:27 -08:00
Timothy 6fbcdc1d87 fix: auto install node 20 2026-03-04 12:11:29 -08:00
bryan 69a11af949 chore: best effort alignment of windows quickstart 2026-03-04 11:43:50 -08:00
bryan 9ef272020e chore: added llm key health check 2026-03-04 11:35:12 -08:00
bryan 258cfe7de5 chore: added easy way to update llm provider key 2026-03-04 10:42:57 -08:00
bryan 0d53b21133 chore: doc updates about hive open 2026-03-04 10:33:34 -08:00
Fernando Mano 704a0fd63a feature(WindowsFilesystemSupport): #5677 - Windows File System Support and Testing -- remove testing codeand prepare for PR 2026-03-04 15:09:06 -03:00
bryan 0ccb28ffab fix: enter to use previously configured 2026-03-04 10:05:59 -08:00
Fernando Mano bf4101ac38 feature(WindowsFilesystemSupport): #5677 - Windows File System Support and Testing -- remove testing codeand prepare for PR 2026-03-04 15:03:02 -03:00
bryan b30b571b44 chore: update recommended models 2026-03-04 09:54:29 -08:00
bryan bc44c3a401 chore: make gcu enabled by default 2026-03-04 09:52:42 -08:00
bryan 7fbf57cbb7 fix: linter update 2026-03-04 09:52:16 -08:00
Fernando Mano bc349e8fde feature(WindowsFilesystemSupport): #5677 - Windows File System Support and Testing -- remove testing codeand prepare for PR 2026-03-04 14:41:42 -03:00
bryan 67d094f51a fix: tool tests 2026-03-04 09:22:34 -08:00
bryan 873af04c6e fix: utilize mac keychain for claude code subscription 2026-03-04 09:22:12 -08:00
Shaurya Singh 2f0439dca8 Merge branch 'main' into fix/windows-browser-auto-open 2026-03-04 22:50:39 +05:30
Fernando Mano 8470c6a980 feature(WindowsFilesystemSupport): #5677 - Windows File System Support and Testing 2026-03-04 14:16:48 -03:00
Levin 43092ba1d7 Merge branch 'aden-hive:main' into bug/agent-sessions 2026-03-04 22:33:40 +05:30
bryan 1920192656 feat: hive open cmd 2026-03-04 08:55:18 -08:00
bryan 61487db481 chore: linter fixes 2026-03-04 08:44:04 -08:00
Waryjustice f56feaf821 fix: browser auto-open after quickstart does not work on Windows 2026-03-04 22:12:53 +05:30
Timothy @aden 4cbd5a4c6c Merge pull request #5786 from osb910/fix/charmap-decode-error
fix(core): add utf-8 encoding to backend open calls (micro-fix)
2026-03-04 08:39:10 -08:00
Timothy 65aa5629e8 chore: fix lint 2026-03-04 08:34:01 -08:00
bryan c42c8ba505 Merge branch 'main' into feat/google-scopes 2026-03-04 08:25:29 -08:00
Omar Shareef 7193d09bed formatting warning fix 2026-03-04 16:43:46 +02:00
Omar Shareef 49f8fae0b4 fix: systematically enforce UTF-8 encoding across tools and core to fix Windows charmap decode errors 2026-03-04 16:04:53 +02:00
Omar Shareef e1a490756e fix: systematically enforce UTF-8 encoding across tools and core to fix Windows charmap decode errors 2026-03-04 15:58:03 +02:00
code-Miracle49 c313ea7ee2 micro-fix: remove duplicate _execute_subagent method in EventLoopNode 2026-03-04 12:54:43 +01:00
Omar Shareef 91bfaf36e3 fix(core): add utf-8 encoding to backend open calls
This fixes a charmap decoding error on Windows when opening agent files without explicitly specifying the encoding.
2026-03-04 13:32:59 +02:00
levxn e3ea9212dd latest upstream and MC resolved in workspace.tsx 2026-03-04 12:08:14 +05:30
kostasuser01gr 99d41d8cc6 docs: update roadmap to reflect completed features (refs #4780) 2026-03-04 08:37:14 +02:00
levxn 8988c1e760 session management and ability to converse from where the chat was left off, fix v1 2026-03-04 11:40:44 +05:30
Timothy @aden 465adf5b1f Merge pull request #5767 from aden-hive/feat/integrations
Feat/integrations
2026-03-03 22:04:08 -08:00
RichardTang-Aden 132d00d166 Merge pull request #5769 from aden-hive/queen-mode-separation
Release / Create Release (push) Waiting to run
Queen mode separation: building, staging, and running modes
2026-03-03 21:31:23 -08:00
bryan b1a5f8e730 chore: tool test fixes 2026-03-03 21:01:19 -08:00
Timothy 8018325923 style: fix all ruff lint errors (E501, E722, E741, F841)
- Break long lines (E501) across 25+ files
- Replace bare except with except Exception (E722)
- Rename ambiguous variable `l` to `item` (E741)
- Prefix unused variables with underscore (F841)
2026-03-03 20:42:30 -08:00
Timothy b4cf10214b chore: lint issues 2026-03-03 20:38:30 -08:00
Bryan @ Aden c7818c2c33 Merge pull request #5766 from aden-hive/fix/credential-modal-delete
(micro-fix): Fix/credential modal delete
2026-03-04 04:38:23 +00:00
Timothy e421bcc326 chore: lint issues 2026-03-03 20:36:28 -08:00
Timothy 9b76ac48b7 chore: new depedency 2026-03-03 20:23:10 -08:00
bryan 06a9adb051 chore: linter fix 2026-03-03 20:15:42 -08:00
bryan 9ce753055c feat: meeting scheduler agent 2026-03-03 20:01:58 -08:00
bryan 0ce87b5155 refactor: update calendar list events tool 2026-03-03 20:01:42 -08:00
Timothy @aden 6c8c6d7048 Merge pull request #5234 from Antiarin/fix/guardian-self-trigger-loop
fix(tui): fix pause/stop to cancel all running tasks across all graphs
2026-03-03 18:17:15 -08:00
Timothy 6da48eac6f feat: split tool loading into verified and unverified tiers
register_all_tools() now only loads verified (stable) tools by default.
Pass include_unverified=True to also load new/community integrations.
This prevents unverified tools from being loaded in production.

Also fixes duplicate register_brevo and register_pushover calls.
2026-03-03 17:54:45 -08:00
Timothy 638ff04e24 fix: remove duplicate community tool directories and fix credential wiring
- Remove s3_tool (duplicate of aws_s3_tool), power_bi_tool (duplicate of
  powerbi_tool), x_tool (duplicate of twitter_tool)
- Remove integrations/plaid (duplicate of plaid_tool), integrations/sap_s4hana
  (duplicate of sap_tool), stray tools/mssql.py
- Add help key to credential error responses across 14 tool modules
- Fix health checker registry keys (calendly -> calendly_pat, lusha -> lusha_api_key)
- Add health_check_endpoint to calendly and lusha credential specs
- Fix Trello env var (TRELLO_TOKEN -> TRELLO_API_TOKEN) and remove duplicate
  Trello specs from hubspot.py
- Add credential_group="aws" to AWS S3 and Redshift specs sharing env vars
- Update conftest UNREGISTERED_COMMUNITY_MODULES to only contain mssql_tool
2026-03-03 17:46:28 -08:00
bryan d0e7aa14b6 fix: hide delete button for Aden-managed credentials 2026-03-03 17:36:04 -08:00
bryan 59fee56c54 fix: share server credential store with runner to avoid redundant Aden syncs 2026-03-03 17:35:24 -08:00
bryan 2207306169 fix: resolve MCP server cwd from repo root instead of agent path 2026-03-03 17:34:51 -08:00
bryan 730370a007 test: update calendar and health check tests 2026-03-03 15:42:22 -08:00
bryan f87909109c refactor: simplify health check system 2026-03-03 15:42:07 -08:00
bryan d6a6d8b5ef refactor: unify Google OAuth to single credential 2026-03-03 15:41:53 -08:00
bryan 57563abfa7 feat: add Google Sheets tool 2026-03-03 15:41:26 -08:00
Timothy 4ff531dec7 fix: update expected health checkers set (add calendly, zoho_crm) 2026-03-03 14:10:34 -08:00
Timothy 4f8b3d7aff fix: update credential specs for community Linear/Trello tools, skip unregistered community modules 2026-03-03 14:09:04 -08:00
Timothy 210fa9c474 fix: use community Brevo implementation (6 tools), remove orphaned x_tool test 2026-03-03 14:06:00 -08:00
Timothy 25361cac8c fix: align tests with community implementations, revert Reddit to httpx (praw unavailable) 2026-03-03 14:02:33 -08:00
Timothy 28defebd6d fix: remove community youtube_transcript tool.py requiring uninstalled SDK 2026-03-03 13:58:45 -08:00
Timothy d58f3103dd fix: guard register_tools for s3_tool and mssql_tool when SDK not available 2026-03-03 13:54:46 -08:00
Timothy 5d1ed35660 fix: remove shell heredoc artifacts from community power_bi_tool 2026-03-03 13:52:20 -08:00
Timothy 1f3e305534 fix: guard optional SDK imports (boto3, pyodbc) and remove s3_tool registration 2026-03-03 13:51:04 -08:00
Timothy 7d8fdd279c fix: revert Asana to httpx-based implementation (asana SDK not available) 2026-03-03 13:33:35 -08:00
Timothy bb061b770f merge: incorporate QuickBooks community PR #4158
# Conflicts:
#	examples/templates/deep_research_agent/config.py
#	examples/templates/tech_news_reporter/config.py
#	tools/README.md
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/quickbooks.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/quickbooks_tool/__init__.py
#	tools/src/aden_tools/tools/quickbooks_tool/quickbooks_tool.py
#	tools/tests/tools/test_quickbooks_tool.py
2026-03-03 13:27:04 -08:00
Timothy a8768b9ed6 merge: incorporate MSSQL community PR #4200
# Conflicts:
#	tools/pyproject.toml
#	tools/src/aden_tools/credentials/integrations.py
#	tools/src/aden_tools/tools/__init__.py
2026-03-03 13:26:36 -08:00
Timothy b437aa5f6c merge: incorporate Linear community PR #3585
# Conflicts:
#	.claude/skills/hive-credentials/SKILL.md
#	tools/README.md
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/linear_tool/__init__.py
#	tools/src/aden_tools/tools/linear_tool/linear_tool.py
2026-03-03 13:24:57 -08:00
Timothy 9248182570 merge: incorporate Trello community PR #3376
# Conflicts:
#	tools/README.md
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/trello_tool/__init__.py
#	tools/src/aden_tools/tools/trello_tool/trello_tool.py
#	tools/tests/tools/test_trello_tool.py
2026-03-03 13:24:23 -08:00
Timothy 7c77c7170f merge: incorporate YouTube Transcript community PR #3520
# Conflicts:
#	tools/pyproject.toml
#	tools/src/aden_tools/tools/__init__.py
2026-03-03 13:22:46 -08:00
Timothy 85fcb6516c merge: incorporate Redshift community PR #3533
# Conflicts:
#	tools/pyproject.toml
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/redshift_tool/__init__.py
#	tools/src/aden_tools/tools/redshift_tool/redshift_tool.py
#	tools/tests/tools/test_redshift_tool.py
2026-03-03 13:17:41 -08:00
Timothy e8e76d85f7 merge: incorporate Pushover community PR #5424
# Conflicts:
#	tools/src/aden_tools/tools/pushover_tool/__init__.py
#	tools/src/aden_tools/tools/pushover_tool/pushover_tool.py
2026-03-03 13:17:18 -08:00
Timothy 5aaa5ae4d5 merge: incorporate Twitter/X community PR #3807
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/tests/test_credentials.py
2026-03-03 13:16:45 -08:00
Timothy c3a8ee9c7b merge: incorporate Calendly community PR #3947
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/calendly.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/calendly_tool/__init__.py
#	tools/src/aden_tools/tools/calendly_tool/calendly_tool.py
#	tools/tests/test_health_checks.py
#	tools/tests/tools/test_calendly_tool.py
2026-03-03 13:14:20 -08:00
Timothy 5d07a8aba5 merge: incorporate Airtable community PR #3953
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/airtable.py
#	tools/src/aden_tools/credentials/health_check.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/airtable_tool/__init__.py
#	tools/src/aden_tools/tools/airtable_tool/airtable_tool.py
#	tools/tests/test_health_checks.py
#	tools/tests/tools/test_airtable_tool.py
2026-03-03 13:13:47 -08:00
Timothy d18e0594b8 merge: incorporate Reddit community PR #3963
# Conflicts:
#	tools/pyproject.toml
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/health_check.py
#	tools/src/aden_tools/credentials/reddit.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/reddit_tool/__init__.py
#	tools/src/aden_tools/tools/reddit_tool/reddit_tool.py
#	tools/tests/tools/test_reddit_tool.py
#	uv.lock
2026-03-03 13:12:55 -08:00
Timothy 26dcc86a24 merge: incorporate Zoho CRM community PR #4713
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/zoho_crm_tool/__init__.py
#	tools/src/aden_tools/tools/zoho_crm_tool/zoho_crm_tool.py
#	tools/tests/test_health_checks.py
2026-03-03 13:11:51 -08:00
Timothy e928ad19e5 merge: incorporate Lusha community PR #4714
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/lusha.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/lusha_tool/__init__.py
#	tools/src/aden_tools/tools/lusha_tool/lusha_tool.py
#	tools/tests/tools/test_lusha_tool.py
2026-03-03 13:11:33 -08:00
Timothy 6768aaa575 merge: incorporate Apify community PR #4770
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/apify.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/apify_tool/__init__.py
#	tools/src/aden_tools/tools/apify_tool/apify_tool.py
#	tools/tests/tools/test_apify_tool.py
2026-03-03 13:10:45 -08:00
Timothy f561aacbfc merge: incorporate Attio community PR #4832
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/attio.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/attio_tool/__init__.py
#	tools/src/aden_tools/tools/attio_tool/attio_tool.py
2026-03-03 13:10:09 -08:00
Timothy d9edd7adf7 merge: incorporate Asana community PR #4857
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/asana.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/asana_tool/__init__.py
#	tools/tests/tools/test_asana_tool.py
2026-03-03 13:08:30 -08:00
Timothy b4a5323009 merge: incorporate Brevo community PR #5136
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/brevo.py
#	tools/src/aden_tools/tools/brevo_tool/__init__.py
#	tools/src/aden_tools/tools/brevo_tool/brevo_tool.py
2026-03-03 13:04:29 -08:00
Timothy ade8b5b9a7 merge: incorporate Databricks community PR #5428
# Conflicts:
#	tools/src/aden_tools/credentials/__init__.py
#	tools/src/aden_tools/credentials/databricks.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/src/aden_tools/tools/databricks_tool/__init__.py
#	tools/src/aden_tools/tools/databricks_tool/databricks_tool.py
#	tools/tests/tools/test_databricks_tool.py
2026-03-03 13:02:30 -08:00
Timothy e4ace3d484 merge: incorporate YouTube community PR #5673 (resolve conflicts, preserve README) 2026-03-03 12:29:32 -08:00
Timothy f3dd25adc5 merge: incorporate Power BI community PR #4341 2026-03-03 12:27:06 -08:00
Timothy ec251f8168 merge: incorporate SAP S/4HANA community PR #5519 2026-03-03 12:27:02 -08:00
Timothy 1bb9579dc5 merge: incorporate Plaid community PR #5518 2026-03-03 12:26:56 -08:00
Timothy 7ebf4146ce merge: incorporate AWS S3 community PR #5521 2026-03-03 12:26:50 -08:00
Timothy e0e05f3488 chore: register Obsidian tool in tool/credential registries 2026-03-03 11:55:12 -08:00
Timothy c92f2510c8 test: add Obsidian tool unit tests (read, write, append, search, list, active) 2026-03-03 11:55:12 -08:00
Timothy ea1fbe9ee1 chore: add Obsidian credential spec (REST API key) 2026-03-03 11:55:11 -08:00
Timothy 84a0be0179 feat: add Obsidian knowledge management integration (#3741)
6 tools: obsidian_read_note, obsidian_write_note, obsidian_append_note,
obsidian_search, obsidian_list_files, obsidian_get_active.
Uses Local REST API plugin with Bearer token auth. Supports vault
browsing, full-text search, and note CRUD with frontmatter metadata.
2026-03-03 11:55:04 -08:00
Timothy 1b5780461e chore: register Langfuse tool in tool/credential registries 2026-03-03 11:42:49 -08:00
Timothy c8d35b63a4 test: add Langfuse tool unit tests (traces, scores, prompts) 2026-03-03 11:42:49 -08:00
Timothy feb1ebae04 chore: add Langfuse credential specs (public key, secret key) 2026-03-03 11:42:48 -08:00
Timothy efe49d0a5b feat: add Langfuse LLM observability integration (#5322)
6 tools: langfuse_list_traces, langfuse_get_trace, langfuse_list_scores,
langfuse_create_score, langfuse_list_prompts, langfuse_get_prompt.
Uses HTTP Basic Auth with public/secret key pair. Supports cloud and
self-hosted instances with offset-based pagination.
2026-03-03 11:41:11 -08:00
Timothy e50a5ea22a chore: register Zoom and n8n tools in tool/credential registries 2026-03-03 11:31:25 -08:00
Timothy 6382c94d0a test: add n8n tool unit tests (workflows, executions, activate/deactivate) 2026-03-03 11:31:21 -08:00
Timothy 58ce84c9cc chore: add n8n credential specs (API key, base URL) 2026-03-03 11:31:20 -08:00
Timothy 08fd6ff765 feat: add n8n workflow automation integration (#2931)
6 tools: n8n_list_workflows, n8n_get_workflow, n8n_activate_workflow,
n8n_deactivate_workflow, n8n_list_executions, n8n_get_execution.
Uses X-N8N-API-KEY header auth with configurable base URL.
Supports cursor-based pagination and execution status filtering.
2026-03-03 11:31:15 -08:00
Timothy a9cb79909c test: add Zoom tool unit tests (user, meetings, recordings) 2026-03-03 11:31:07 -08:00
Timothy 852f8ccd94 chore: add Zoom credential spec (Server-to-Server OAuth token) 2026-03-03 11:31:07 -08:00
Timothy 9388ef3e99 feat: add Zoom meeting management integration (#2867)
6 tools: zoom_get_user, zoom_list_meetings, zoom_get_meeting,
zoom_create_meeting, zoom_delete_meeting, zoom_list_recordings.
Uses Server-to-Server OAuth Bearer token. Supports token-based
pagination and cloud recording retrieval by date range.
2026-03-03 11:31:00 -08:00
Timothy 04afb0c4bb chore: register Salesforce and Shopify tools in tool/credential registries 2026-03-03 11:22:40 -08:00
Timothy a07fd44de3 test: add Shopify tool unit tests (orders, products, customers, search) 2026-03-03 11:22:35 -08:00
Timothy f6c1b13846 chore: add Shopify credential specs (access token, store name) 2026-03-03 11:22:35 -08:00
Timothy 654fa3dd1f feat: add Shopify Admin REST API integration - orders, products, customers (#2984)
6 tools: shopify_list_orders, shopify_get_order, shopify_list_products,
shopify_get_product, shopify_list_customers, shopify_search_customers.
Uses X-Shopify-Access-Token header auth with store subdomain.
2026-03-03 11:22:29 -08:00
Timothy 8183449d27 test: add Salesforce CRM tool unit tests (SOQL, CRUD, describe, list objects) 2026-03-03 11:22:16 -08:00
Timothy a9acfb86ad chore: add Salesforce credential specs (access token, instance URL) 2026-03-03 11:22:15 -08:00
Timothy d7d070ac5f feat: add Salesforce CRM integration - SOQL, records, and metadata (#2916)
6 tools: salesforce_soql_query, salesforce_get_record, salesforce_create_record,
salesforce_update_record, salesforce_describe_object, salesforce_list_objects.
Uses OAuth2 Bearer token auth with instance URL. Supports pagination via
nextRecordsUrl and field-level describe with picklist values.
2026-03-03 11:22:08 -08:00
Timothy 8c01b573ce chore: register Redshift and SAP S/4HANA in tool/credential registries 2026-03-03 11:11:12 -08:00
Timothy 7744f21b9d test: add SAP S/4HANA tool unit tests (POs, partners, products, sales orders) 2026-03-03 11:11:08 -08:00
Timothy 9ed23a235f chore: add SAP S/4HANA credential specs (base URL, username, password) 2026-03-03 11:11:07 -08:00
Timothy e88328321f feat: add SAP S/4HANA Cloud read-only procurement integration (#3182) 2026-03-03 11:11:06 -08:00
Timothy a4c516bea1 test: add Redshift tool unit tests (execute, describe, results, databases, tables) 2026-03-03 11:11:00 -08:00
Timothy 1c932a04ef chore: add Redshift credential specs (AWS access key, secret key) 2026-03-03 11:11:00 -08:00
Timothy 76d34be4c2 feat: add Amazon Redshift Data API integration - SQL and schema browsing (#3267) 2026-03-03 11:10:59 -08:00
Timothy d6e8afe316 chore: register Azure SQL and Kafka in tool/credential registries 2026-03-03 11:03:31 -08:00
Timothy a04f2bcf99 test: add Kafka tool unit tests (topics, produce, consumer groups) 2026-03-03 11:03:27 -08:00
Timothy c138e7c638 chore: add Kafka credential specs (REST URL, cluster ID) 2026-03-03 11:03:27 -08:00
Timothy fc08c7007f feat: add Apache Kafka integration via Confluent REST Proxy (#4774) 2026-03-03 11:03:26 -08:00
Timothy d559bb3446 test: add Azure SQL tool unit tests (servers, databases, firewall rules) 2026-03-03 11:03:18 -08:00
Timothy 55a8c39e4b chore: add Azure SQL credential specs (token, subscription ID) 2026-03-03 11:03:17 -08:00
Timothy 02d6f10e5f feat: add Azure SQL Database management integration (#3377) 2026-03-03 11:03:16 -08:00
Timothy 77428a91cc chore: register Power BI and Snowflake in tool/credential registries 2026-03-03 10:56:46 -08:00
Timothy 51403dc276 test: add Snowflake tool unit tests (execute, status, cancel) 2026-03-03 10:56:43 -08:00
Timothy 914a07a35d chore: add Snowflake credential specs (account, token) 2026-03-03 10:56:42 -08:00
Timothy 3c70d7b424 feat: add Snowflake SQL REST API integration (#3230) 2026-03-03 10:56:41 -08:00
Timothy ce1ee4ff17 test: add Power BI tool unit tests (workspaces, datasets, reports, refresh) 2026-03-03 10:56:35 -08:00
Timothy fca41d9bda chore: add Power BI credential spec (POWERBI_ACCESS_TOKEN) 2026-03-03 10:56:34 -08:00
Timothy ff889e02f7 feat: add Power BI integration - workspaces, datasets, reports (#3973) 2026-03-03 10:56:34 -08:00
Timothy 43ab460462 chore: register Terraform Cloud and Lusha in tool/credential registries 2026-03-03 10:49:21 -08:00
Timothy caa06e266b test: add Lusha tool unit tests (enrich, search, usage) 2026-03-03 10:49:17 -08:00
Timothy 3622ca78ee chore: add Lusha credential spec (LUSHA_API_KEY) 2026-03-03 10:49:17 -08:00
Timothy 019e3f9659 feat: add Lusha B2B contact and company enrichment integration (#3461) 2026-03-03 10:49:16 -08:00
Timothy 208cb579a2 test: add Terraform Cloud tool unit tests (workspaces, runs) 2026-03-03 10:49:09 -08:00
Timothy 17de7e4485 chore: add Terraform Cloud credential spec (TFC_TOKEN) 2026-03-03 10:49:08 -08:00
Timothy 810616eee1 feat: add Terraform Cloud integration - workspaces and runs (#4773) 2026-03-03 10:48:41 -08:00
Timothy 191f583669 chore: register Twitter/X and Tines in tool/credential registries 2026-03-03 10:35:46 -08:00
Timothy 1d638cc18e test: add Tines tool unit tests (stories, actions, logs) 2026-03-03 10:35:42 -08:00
Timothy 3efa1f3b88 chore: add Tines credential specs (domain, api_key) 2026-03-03 10:35:42 -08:00
Timothy 4daa33db09 feat: add Tines integration - security automation stories and actions
Implements 5 tools via Tines REST API:
- tines_list_stories: List workflow stories with search/filter
- tines_get_story: Get story details including entry/exit agents
- tines_list_actions: List actions (agents) in stories
- tines_get_action: Get action details with sources/receivers
- tines_get_action_logs: Get action execution logs by level

Uses Bearer token auth with tenant domain.
2026-03-03 10:35:37 -08:00
Timothy fab2fb0056 test: add Twitter/X tool unit tests (search, user, timeline, tweet) 2026-03-03 10:35:29 -08:00
Timothy ce885c120e chore: add Twitter/X credential spec (bearer_token) 2026-03-03 10:35:28 -08:00
Timothy 75b53c47ff feat: add Twitter/X integration - tweet search and user lookup via API v2
Implements 4 tools via X API v2:
- twitter_search_tweets: Search recent tweets with query operators
- twitter_get_user: Get user profile by username
- twitter_get_user_tweets: Get user timeline
- twitter_get_tweet: Get tweet details by ID

Uses Bearer token auth (app-only, read access).
2026-03-03 10:35:21 -08:00
Timothy 2936f73707 chore: register AWS S3 and QuickBooks in tool/credential registries 2026-03-03 10:22:46 -08:00
Timothy e26426b138 test: add QuickBooks tool unit tests (query, entities, invoices) 2026-03-03 10:22:42 -08:00
Timothy 62cacb8e28 chore: add QuickBooks credential specs (access_token, realm_id) 2026-03-03 10:22:42 -08:00
Timothy f3e37190ce feat: add QuickBooks Online integration - accounting API
Implements 5 tools via QuickBooks Online API v3:
- quickbooks_query: Query entities with SQL-like syntax
- quickbooks_get_entity: Get entity by type and ID
- quickbooks_create_customer: Create customers
- quickbooks_create_invoice: Create invoices with line items
- quickbooks_get_company_info: Get company details

Uses OAuth 2.0 Bearer token auth. Supports sandbox mode.
2026-03-03 10:22:35 -08:00
Timothy 0863bbbd2f test: add AWS S3 tool unit tests (buckets, objects, get, put, delete) 2026-03-03 10:22:25 -08:00
Timothy b23fa1daad chore: add AWS S3 credential specs (access_key_id, secret_access_key) 2026-03-03 10:22:24 -08:00
Timothy 05cc1ce599 feat: add AWS S3 integration - object storage via REST API with SigV4
Implements 5 tools via AWS S3 REST API:
- s3_list_buckets: List all buckets in the account
- s3_list_objects: List objects with prefix/delimiter filtering
- s3_get_object: Get object content and metadata
- s3_put_object: Upload text objects
- s3_delete_object: Delete objects

Uses AWS Signature V4 signing (no boto3 dependency).
2026-03-03 10:22:16 -08:00
Timothy e6939f8d51 chore: register PagerDuty and Calendly in tool/credential registries 2026-03-03 10:13:18 -08:00
Timothy 801fef12e1 test: add Calendly tool unit tests (user, events, invitees) 2026-03-03 10:13:14 -08:00
Timothy 5845629175 chore: add Calendly credential spec (personal_access_token) 2026-03-03 10:13:13 -08:00
Timothy 11b916301a feat: add Calendly integration - scheduling events and invitees
Implements 5 tools via Calendly API v2:
- calendly_get_current_user: Get user URI and profile info
- calendly_list_event_types: List meeting templates
- calendly_list_scheduled_events: List booked meetings with date filters
- calendly_get_scheduled_event: Get event details by URI
- calendly_list_invitees: List invitees for an event

Uses Bearer token auth (Personal Access Token).
2026-03-03 10:13:07 -08:00
Timothy aa5d80b1d2 test: add PagerDuty tool unit tests (incidents, services) 2026-03-03 10:13:02 -08:00
Timothy aa5f990acd chore: add PagerDuty credential specs (api_key, from_email) 2026-03-03 10:13:01 -08:00
Timothy 9764c82c2a feat: add PagerDuty integration - incident management and services
Implements 5 tools via PagerDuty REST API v2:
- pagerduty_list_incidents: List incidents with status/urgency/date filters
- pagerduty_get_incident: Get incident details by ID
- pagerduty_create_incident: Create incidents on a service
- pagerduty_update_incident: Acknowledge or resolve incidents
- pagerduty_list_services: List services with name search

Uses Token auth header, From header for write operations.
2026-03-03 10:12:55 -08:00
Timothy 543a71eb6c chore: register MongoDB and Airtable in tool/credential registries 2026-03-03 10:06:12 -08:00
Timothy 8285593c13 test: add Airtable tool unit tests (records, bases, schema) 2026-03-03 10:06:08 -08:00
Timothy 6fbfe773fb chore: add Airtable credential spec (personal_access_token) 2026-03-03 10:06:07 -08:00
Timothy a8c54b1e5f feat: add Airtable integration - record CRUD and base metadata
Implements 6 tools via Airtable Web API:
- airtable_list_records: List records with filters, sort, field selection
- airtable_get_record: Get a single record by ID
- airtable_create_records: Create up to 10 records per request
- airtable_update_records: Partial update up to 10 records per request
- airtable_list_bases: List accessible bases
- airtable_get_base_schema: Get table and field schema for a base

Uses Bearer token auth (Personal Access Token).
2026-03-03 10:06:03 -08:00
Timothy a5323abfca test: add MongoDB tool unit tests (find, insert, update, delete, aggregate) 2026-03-03 10:05:53 -08:00
Timothy ba4df2d2c4 chore: add MongoDB credential specs (data_api_url, api_key, data_source) 2026-03-03 10:05:52 -08:00
Timothy 6510633a8c feat: add MongoDB Atlas Data API integration - document CRUD and aggregation
Implements 6 tools via MongoDB Atlas Data API:
- mongodb_find: Find documents with filters, projection, sort, limit
- mongodb_find_one: Find a single document
- mongodb_insert_one: Insert a document
- mongodb_update_one: Update a document with MongoDB operators
- mongodb_delete_one: Delete a document
- mongodb_aggregate: Run aggregation pipelines

Uses API key auth header. All endpoints are POST.
2026-03-03 10:05:42 -08:00
Timothy 9172e5f46b chore: register Twilio and Zendesk in tool/credential registries 2026-03-03 09:56:14 -08:00
Timothy ed3e3848c0 test: add Zendesk tool unit tests (list, get, create, update, search) 2026-03-03 09:56:10 -08:00
Timothy ee90185d5c chore: add Zendesk credential specs (subdomain, email, api_token) 2026-03-03 09:56:09 -08:00
Timothy 6eb2633677 feat: add Zendesk integration - ticket management and search
Implements 5 tools via Zendesk Support API v2:
- zendesk_list_tickets: List tickets with status/sort filters
- zendesk_get_ticket: Get ticket details by ID
- zendesk_create_ticket: Create tickets with priority/type/tags
- zendesk_update_ticket: Update ticket fields and add comments
- zendesk_search_tickets: Search tickets with Zendesk query syntax

Uses Basic auth (email/token:api_token).
2026-03-03 09:56:00 -08:00
Timothy c1f215dcf2 test: add Twilio tool unit tests (SMS, WhatsApp, list, get) 2026-03-03 09:55:50 -08:00
Timothy 97cc9a1045 chore: add Twilio credential specs (account_sid, auth_token) 2026-03-03 09:55:49 -08:00
Timothy 5f7b02a4b7 feat: add Twilio integration - SMS and WhatsApp messaging
Implements 4 tools via Twilio REST API:
- twilio_send_sms: Send SMS messages
- twilio_send_whatsapp: Send WhatsApp messages
- twilio_list_messages: List message history with filters
- twilio_get_message: Get message details by SID

Uses Basic auth (AccountSID:AuthToken), form-urlencoded POST.
2026-03-03 09:55:43 -08:00
Timothy e696b41a0e chore: register GitLab and Google Sheets in tool/credential registries 2026-03-03 09:49:23 -08:00
Timothy 1f9acc6135 test: add Google Sheets tool unit tests (metadata, read, batch read) 2026-03-03 09:49:23 -08:00
Timothy 7e8699cb4b chore: add Google Sheets credential spec (api_key) 2026-03-03 09:49:22 -08:00
Timothy fd4fc657d6 feat: add Google Sheets integration - read spreadsheet data via API v4
3 tools: sheets_get_spreadsheet, sheets_read_range, sheets_batch_read.
Uses API key auth for read-only access to public spreadsheets.
2026-03-03 09:49:21 -08:00
Timothy 34403648b9 test: add GitLab tool unit tests (projects, issues, MRs) 2026-03-03 09:49:15 -08:00
Timothy 3795d50eb9 chore: add GitLab credential spec (personal access token) 2026-03-03 09:49:14 -08:00
Timothy 80515dde5a feat: add GitLab integration - projects, issues, merge requests
6 tools: gitlab_list_projects, gitlab_get_project, gitlab_list_issues,
gitlab_get_issue, gitlab_create_issue, gitlab_list_merge_requests.
Supports GitLab.com and self-hosted via configurable base URL.
2026-03-03 09:49:13 -08:00
Timothy efcd296d83 chore: register Notion and Jira tools in tool/credential registries 2026-03-03 09:43:32 -08:00
Timothy 802cb292b0 test: add Jira tool unit tests (issues, projects, comments) 2026-03-03 09:43:32 -08:00
Timothy 8e55f74d73 chore: add Jira credential specs (domain, email, api_token) 2026-03-03 09:43:31 -08:00
Timothy 3d810485a0 feat: add Jira integration - issues, projects, comments via REST API v3
6 tools: jira_search_issues, jira_get_issue, jira_create_issue,
jira_list_projects, jira_get_project, jira_add_comment. Uses Basic auth
with email + API token and Atlassian Document Format for text fields.
2026-03-03 09:43:30 -08:00
Timothy 94cfd48661 test: add Notion tool unit tests (search, pages, databases) 2026-03-03 09:43:16 -08:00
Timothy 87c8e741f3 chore: add Notion credential spec (api_token) 2026-03-03 09:43:15 -08:00
Timothy d0e92ed18d feat: add Notion integration - pages, databases, and search
5 tools: notion_search, notion_get_page, notion_create_page,
notion_query_database, notion_get_database. Uses Bearer auth
with Notion internal integration token.
2026-03-03 09:43:14 -08:00
Timothy 1927045519 chore: register Greenhouse and YouTube Transcript in tool/credential registries 2026-03-03 09:36:47 -08:00
Timothy 68cffb86c9 test: add YouTube Transcript tool unit tests (get, list transcripts) 2026-03-03 09:36:47 -08:00
Timothy 5bec989647 feat: add YouTube Transcript integration - captions and transcript retrieval
2 tools: youtube_get_transcript, youtube_list_transcripts.
Uses youtube-transcript-api library, no API key required.
2026-03-03 09:36:46 -08:00
Timothy 66f5d2f36c test: add Greenhouse tool unit tests (jobs, candidates, applications) 2026-03-03 09:36:40 -08:00
Timothy 941f815254 chore: add Greenhouse credential spec (api_token) 2026-03-03 09:36:39 -08:00
Timothy 42afd10518 feat: add Greenhouse integration - ATS jobs, candidates, applications
6 tools: greenhouse_list_jobs, greenhouse_get_job, greenhouse_list_candidates,
greenhouse_get_candidate, greenhouse_list_applications, greenhouse_get_application.
Uses Harvest API v1 with Basic auth (API token).
2026-03-03 09:36:38 -08:00
Timothy 3efa285a59 chore: register Cloudinary and Reddit tools in tool/credential registries 2026-03-03 09:31:22 -08:00
Timothy 4f2b4172b4 test: add Reddit tool unit tests (search, posts, comments, user) 2026-03-03 09:31:18 -08:00
Timothy 0d7de71b94 chore: add Reddit credential specs (client_id, client_secret) 2026-03-03 09:31:17 -08:00
Timothy f0f5b4bede feat: add Reddit integration - search, posts, comments, user info
4 tools: reddit_search, reddit_get_posts, reddit_get_comments, reddit_get_user.
Uses OAuth2 client_credentials flow for app-only access.
2026-03-03 09:31:17 -08:00
Timothy bfd27e97d3 test: add Cloudinary tool unit tests (upload, list, get, delete, search) 2026-03-03 09:31:10 -08:00
Timothy f2def27390 chore: add Cloudinary credential specs (cloud_name, api_key, api_secret) 2026-03-03 09:31:10 -08:00
Timothy b3f7bd6cc0 feat: add Cloudinary integration - upload, manage, search media assets
5 tools: cloudinary_upload, cloudinary_list_resources, cloudinary_get_resource,
cloudinary_delete_resource, cloudinary_search. Uses Basic auth with
API key/secret and supports image, video, and raw resource types.
2026-03-03 09:31:09 -08:00
Timothy 0e8e78dc5b chore: register Trello and Confluence tools in tool/credential registries 2026-03-03 09:22:03 -08:00
Timothy b259d85776 test: add Confluence tool tests (9 tests) 2026-03-03 09:22:02 -08:00
Timothy 175d9c3b7c feat: add Confluence credential spec with Basic auth (email + API token) 2026-03-03 09:21:55 -08:00
Timothy a2a810aabf feat: add Confluence integration - spaces, pages, content search via CQL 2026-03-03 09:21:54 -08:00
Timothy 175c7cfd51 test: add Trello tool tests (12 tests) 2026-03-03 09:21:47 -08:00
Timothy 5ada973d38 feat: add Trello credential spec with API key and token auth 2026-03-03 09:21:39 -08:00
Timothy 0103276136 feat: add Trello integration - boards, lists, cards management 2026-03-03 09:21:37 -08:00
Timothy 1d9e8ec138 chore: register HuggingFace tool in tool/credential registries 2026-03-03 09:11:59 -08:00
Timothy 83ac2e71bb test: add HuggingFace tool tests (10 tests) 2026-03-03 09:11:56 -08:00
Timothy 0b35a729a7 feat: add HuggingFace credential spec with token auth 2026-03-03 09:11:55 -08:00
Timothy 56723a519a feat: add HuggingFace Hub integration - models, datasets, spaces search 2026-03-03 09:11:49 -08:00
Timothy ebff394c76 chore: register Plaid tool in tool/credential registries 2026-03-03 09:08:44 -08:00
Timothy ceecc97bc8 test: add Plaid tool tests (13 tests) 2026-03-03 09:08:40 -08:00
Timothy 313154f880 feat: add Plaid credential spec with client_id and secret auth 2026-03-03 09:08:38 -08:00
Timothy 3eb6417cdc feat: add Plaid integration - accounts, balances, transactions, institutions 2026-03-03 09:08:29 -08:00
Timothy 1b35d6ca0a chore: register Pinecone tool in tool/credential registries 2026-03-03 09:05:20 -08:00
Timothy 1d89f0ba9d test: add Pinecone tool tests (18 tests) 2026-03-03 09:05:16 -08:00
Timothy 864df0e21a feat: add Pinecone credential spec with API key auth 2026-03-03 09:05:14 -08:00
Timothy 3f626decc4 feat: add Pinecone vector database integration - indexes, vectors, queries 2026-03-03 09:05:06 -08:00
Timothy bf1760b1a9 chore: register DuckDuckGo tool in tool registry 2026-03-03 08:56:06 -08:00
Timothy 8a58ea6344 test: add DuckDuckGo tool tests (6 tests) 2026-03-03 08:56:06 -08:00
Timothy 662ff4c35f feat: add DuckDuckGo search integration - web search, news, images 2026-03-03 08:56:01 -08:00
Timothy af02352b49 chore: register Linear tool in tool/credential registries 2026-03-03 08:43:41 -08:00
Timothy db9f987d46 test: add Linear tool tests (10 tests) 2026-03-03 08:43:41 -08:00
Timothy 8490ce1389 feat: add Linear credential spec with API key auth 2026-03-03 08:43:41 -08:00
Timothy 55ea9a56a4 feat: add Linear integration - issues, projects, teams, search via GraphQL 2026-03-03 08:43:41 -08:00
Timothy bd2381b10d chore: register Asana tool in tool/credential registries 2026-03-03 08:40:02 -08:00
Timothy 443de755bd test: add Asana tool tests (12 tests) 2026-03-03 08:40:02 -08:00
Timothy 55ec5f14ee feat: add Asana credential spec with PAT auth 2026-03-03 08:40:02 -08:00
Timothy 2e019302c9 feat: add Asana integration - tasks, projects, workspaces, search 2026-03-03 08:40:02 -08:00
Timothy b1e829644b chore: register Yahoo Finance tool in tool registry 2026-03-03 08:36:20 -08:00
Timothy 18f773e91b test: add Yahoo Finance tool tests (8 tests) 2026-03-03 08:36:19 -08:00
Timothy 987cfee930 feat: add Yahoo Finance integration - quotes, history, financials, company info 2026-03-03 08:36:19 -08:00
Timothy 57f6b8498a chore: register Google Search Console tool in tool/credential registries 2026-03-03 08:34:30 -08:00
Timothy 9f0d35977c test: add Google Search Console tool tests (10 tests) 2026-03-03 08:34:30 -08:00
Timothy e5910bbf2f feat: add Google Search Console credential spec with OAuth2 auth 2026-03-03 08:34:30 -08:00
Timothy 0015bf7b38 feat: add Google Search Console integration - analytics, sitemaps, URL inspection 2026-03-03 08:34:30 -08:00
Timothy a6b9234abb chore: register Zoho CRM tool in tool/credential registries 2026-03-03 08:32:13 -08:00
Timothy 086f3942b8 test: add Zoho CRM tool tests (12 tests) 2026-03-03 08:32:13 -08:00
Timothy 924f4abede feat: add Zoho CRM credential spec with OAuth token auth 2026-03-03 08:32:13 -08:00
Timothy 02be91cb08 feat: add Zoho CRM integration - leads, contacts, deals, accounts, notes 2026-03-03 08:32:13 -08:00
Timothy c2298393ab chore: register Apify tool in tool/credential registries 2026-03-03 08:29:33 -08:00
Timothy 4b8c63bf6e test: add Apify tool tests (11 tests) 2026-03-03 08:29:33 -08:00
Timothy e089c3b72c feat: add Apify credential spec with API token auth 2026-03-03 08:29:33 -08:00
Timothy a93983b5db feat: add Apify integration - actors, runs, datasets, key-value stores 2026-03-03 08:29:27 -08:00
Timothy 20f6329004 chore: register Attio tool in tool/credential registries 2026-03-03 08:25:12 -08:00
Timothy 3c2cf71c47 test: add Attio tool tests (14 tests) 2026-03-03 08:25:08 -08:00
Timothy 56288c3137 feat: add Attio credential spec with API key auth 2026-03-03 08:25:04 -08:00
Timothy 79188921a5 feat: add Attio CRM integration - records, lists, notes, tasks 2026-03-03 08:24:58 -08:00
Timothy 5ab66008ae chore: register Pipedrive tool in tool/credential registries 2026-03-03 08:18:45 -08:00
Timothy f38c9ee049 test: add Pipedrive tool tests (16 tests) 2026-03-03 08:18:41 -08:00
Timothy 86f5e71ec2 feat: add Pipedrive credential spec with API token auth 2026-03-03 08:18:29 -08:00
Timothy 1e15cc8495 feat: add Pipedrive CRM integration - deals, contacts, orgs, activities, pipelines 2026-03-03 08:18:24 -08:00
Timothy 077d82ad82 chore: register Docker Hub tool in tool/credential registries 2026-03-03 08:14:27 -08:00
Timothy e4cf7f3da2 test: add Docker Hub tool tests (9 tests) 2026-03-03 08:14:24 -08:00
Timothy e3bdc9e8d7 feat: add Docker Hub credential spec with PAT auth 2026-03-03 08:14:20 -08:00
Timothy f1c1c9aab3 feat: add Docker Hub integration - search, repos, tags, image details 2026-03-03 08:14:15 -08:00
Timothy 4860739a2f chore: register Vercel in tool/credential registries (#5044) 2026-03-03 08:08:16 -08:00
Timothy 791ee40cd6 test: add Vercel tool unit tests (#5044) 2026-03-03 08:08:12 -08:00
Timothy e0191ac52b feat: add Vercel credential spec (#5044) 2026-03-03 08:08:07 -08:00
Timothy e0724df196 feat: add Vercel tool - deployments, projects, domains, env vars (#5044) 2026-03-03 08:08:00 -08:00
Timothy 2a56294638 chore: register Databricks in tool/credential registries (#5167) 2026-03-03 08:05:25 -08:00
Timothy d5cd557013 test: add Databricks tool unit tests (#5167) 2026-03-03 08:05:21 -08:00
Timothy 2a43f23a3d feat: add Databricks credential spec (#5167) 2026-03-03 08:05:03 -08:00
Timothy 69af8f569a feat: add Databricks tool - SQL, jobs, clusters, workspace (#5167) 2026-03-03 08:04:34 -08:00
Timothy 0e86dbcc9b chore: register Redis tool in tool/credential registries (#5370) 2026-03-03 08:01:43 -08:00
Timothy 92c75aa6f5 test: add Redis tool unit tests (#5370) 2026-03-03 08:01:37 -08:00
Timothy be41d848e5 feat: add Redis credential spec (#5370) 2026-03-03 08:01:32 -08:00
Timothy f7c299f6f0 feat: add Redis tool implementation - KV, hash, list, pub/sub (#5370) 2026-03-03 08:01:25 -08:00
Timothy b6a0f65a09 feat: add Pushover push notification integration (#5415)
4 tools: pushover_send, pushover_validate_user, pushover_list_sounds,
pushover_check_receipt. Supports priority levels, HTML, sounds, TTL.
All 12 unit tests and 13 conformance tests passing.
2026-03-03 07:58:29 -08:00
Timothy 1e7b0068ed chore: register Supabase tool in tool/credential registries 2026-03-03 07:54:34 -08:00
Timothy de5105f313 feat: add Supabase integration - DB, Auth, Edge Functions (#5489)
7 tools: supabase_select, supabase_insert, supabase_update, supabase_delete,
supabase_auth_signup, supabase_auth_signin, supabase_edge_invoke.
All 19 unit tests and 13 conformance tests passing.
2026-03-03 07:54:27 -08:00
Timothy 6d32f1bb36 chore: register YouTube and Microsoft Graph tools in tool/credential registries 2026-03-03 07:51:33 -08:00
Timothy 9c316cee28 feat: add Microsoft Graph integration - Outlook, Teams, OneDrive (#5601)
11 tools: outlook_list_messages, outlook_get_message, outlook_send_mail,
teams_list_teams, teams_list_channels, teams_send_channel_message,
teams_get_channel_messages, onedrive_search_files, onedrive_list_files,
onedrive_download_file, onedrive_upload_file.
All 15 unit tests and 13 conformance tests passing.
2026-03-03 07:47:49 -08:00
Timothy 6af4f2d6e6 feat: add YouTube Data API integration (#5603)
8 tools: search_videos, get_video_details, get_channel, list_channel_videos,
get_playlist, search_channels, get_video_comments, get_video_categories.
All 17 unit tests and 13 conformance tests passing.
2026-03-03 07:47:34 -08:00
levxn 7c7b60a5e9 every sessions loads properly without any issue 2026-03-03 19:46:27 +05:30
levxn 3f0b8bff5b fixes a minor unhandled error in event routes 2026-03-03 18:53:43 +05:30
Amdev-5 57651900f1 Merge remote-tracking branch 'origin/main' into lusha 2026-03-03 18:46:12 +05:30
Amdev-5 46b0617018 Merge remote-tracking branch 'origin/main' into lusha
# Conflicts:
#	tools/src/aden_tools/credentials/health_check.py
#	tools/src/aden_tools/tools/__init__.py
#	tools/tests/test_health_checks.py
2026-03-03 18:34:54 +05:30
levxn 91190cf82d restarts with previous session continuity 2026-03-03 17:48:01 +05:30
Aaryann Chandola 87a26db779 Merge branch 'aden-hive:main' into fix/guardian-self-trigger-loop 2026-03-03 11:56:15 +05:30
P Gokul Sree Chandra 7d9bd2e86b feat(tools): add YouTube Data API integration
- Implement 6 YouTube API tools (search videos, get video/channel details, list channel videos, get playlist items, search channels)
- Add YOUTUBE_API_KEY credential spec with help_url and description
- Register YouTube tool in tools/__init__.py
- Add comprehensive test coverage (18 tests) with mocking
- Add detailed README with setup instructions and examples
- Use httpx for HTTP requests to YouTube Data API v3
- Verified with real API integration testing

Implements #5603
2026-03-03 07:35:04 +05:30
Antiarin 20ef5cb14f test(runtime): add async test for canceling multiple tasks across streams 2026-03-03 05:54:42 +05:30
Antiarin 2c3ec7e74c fix(tui): fix pause/stop to cancel all running tasks across all graphs 2026-03-03 05:30:20 +05:30
Amdev-5 cce073dbdb fix(lusha): add pagination and empty filter validation
- Expose page parameter on search_people and search_companies
  (client + MCP tool) enabling access beyond the first 50 results
- Add guard requiring at least one filter on both search endpoints
  to prevent broad requests that burn API credits
- Add unit tests for pagination and empty filter validation
2026-03-02 10:20:08 +05:30
Vasu Bansal 6a92588264 fix(plaid): update v0.6 credential compatibility and stabilize tests 2026-03-01 01:16:16 +05:30
Vasu Bansal 276aad6f0d feat: add Plaid banking integration
- Implement Plaid connector for account balances
- Add transaction history retrieval
- Include GL reconciliation functionality
- Add institution metadata lookup
- Include comprehensive tests and documentation

Closes #4016
2026-03-01 01:16:16 +05:30
Vasu Bansal 10620bda4f fix(sap): update credential-store compatibility and test imports 2026-03-01 01:07:00 +05:30
Vasu Bansal c214401a00 feat(integration): add SAP S/4HANA connector
Add complete SAP S/4HANA integration with:
- Connector for OData API access
- Credential management following Hive patterns
- Unit tests with mocked responses
- Documentation and usage examples

Refs #3182
2026-03-01 01:07:00 +05:30
Vasu Bansal 260ac33324 fix(s3): support v0.6 credential refs and register S3 tools 2026-03-01 00:56:22 +05:30
Vasu Bansal d4cd643860 feat: add AWS S3 integration for cloud object storage
- Add S3Storage class with upload, download, list, delete operations
- Support IAM roles, environment variables, and credential store
- Implement retry logic with adaptive backoff
- Add MCP tools: s3_upload, s3_download, s3_list, s3_delete, s3_check_credentials
- Include comprehensive tests with moto mocking
- Add documentation for setup and IAM permissions

Closes #3012
2026-03-01 00:54:57 +05:30
IamSayeed dc16cfda21 Merge branch 'main' into feature/add-asana-integration 2026-02-28 11:28:43 +05:30
Timothy e1db3a4af9 fix: remove hardcoded anthropic logics 2026-02-27 10:23:59 -08:00
Navya Bijoy ddd30a950d Integration: add Databricks MCP tool integration
Implements the Databricks MCP tool integration for the Hive agent framework
2026-02-26 21:01:59 +05:30
KRYSTALM7 3ca0e63d54 feat(tools): add Pushover push notification integration
Closes #5415
2026-02-26 13:54:34 +00:00
Shivam Shahi– oss/acc 0f8627f17a format 2026-02-22 00:25:15 +05:30
Utkarsh Singh cd0cf69099 feat(tools): add Brevo transactional email and SMS integration
- Add brevo_tool with 6 MCP tools: brevo_send_email, brevo_send_sms,
  brevo_create_contact, brevo_get_contact, brevo_update_contact,
  brevo_get_email_stats
- Add CredentialSpec for BREVO_API_KEY in credentials/brevo.py
- Register brevo_tool in tools/__init__.py and credentials/__init__.py
- Add README with setup instructions and usage examples
- Add 34 unit tests covering all tools, validation and error handling

Closes #5127
2026-02-20 13:19:07 +05:30
Amdev-5 9744363342 fix(lusha): address PR review round 2 — structured filters, pagination, correct types
- search_people: replaced freetext searchText concatenation with proper
  structured Lusha API filters (jobTitles, seniority as list[int],
  departments, locations as dict, company_names, industry_ids, search_text)
- search_companies: added locations, company_names, search_text params;
  made all params optional for flexible queries
- Pagination: exposed limit param (clamped 10-50 per Lusha API constraints)
  on both search tools, replacing hardcoded size=25
- get_signals: changed ids from list[str] to list[int], removed internal
  str-to-int conversion as Lusha IDs are always numeric
- seniority type corrected to list[int] (API rejects string-encoded values
  despite OpenAPI spec suggesting strings — verified via live integration)
- Unit tests updated for all changes (19/19 pass)

Verified against live Lusha API: all 6 tools return correct responses.
2026-02-17 22:00:09 +05:30
Amdev-5 6fe8439e94 fix(lusha): use mainIndustriesIds for company search, safer credential handling
- search_companies: replace names filter with mainIndustriesIds (numeric
  industry IDs) per Lusha API schema. Parameter changed from
  industry: str to industry_ids: list[int] | None.
- _get_api_key: return None instead of raising TypeError on unexpected
  credential type. Lets _get_client handle it with the standard error dict
  pattern used across all tools.
- Updated unit tests for new industry_ids parameter and added test for
  non-string credential handling.
2026-02-17 21:33:02 +05:30
Amdev-5 8e61ffe377 fix(tools): remove invalid searchText field from Lusha prospecting filters
Lusha API rejects filters.companies.include.searchText (HTTP 400).
Replaced with valid 'names' field in search_companies and removed
redundant company searchText from search_people. Updated unit tests.
2026-02-17 21:33:02 +05:30
Amdev-5 723476f7a7 feat(tools): add Lusha MCP integration with credentials and health checks 2026-02-17 21:33:02 +05:30
IamSayeed 0f253027ae Merge branch 'main' into feature/add-asana-integration 2026-02-17 12:20:01 +05:30
Sayeed Rizwan 6053895a82 fix(asana): resolve from PR feedback - refactor client, fix specs, add tests 2026-02-17 12:18:06 +05:30
Shivam Shahi– oss/acc ceffa38717 Merge branch 'main' into feat/zoho-crm 2026-02-17 02:46:29 +05:30
Your hh3538962 ae205fa3f2 fix(tools): address Power BI integration code review feedback
- Fix export endpoint: /Export -> /ExportTo
- Add 202 Accepted response handling
- Add notifyOption to refresh_dataset API call
- Rename format parameter to export_format (avoid shadowing builtin)
- Add PNG support to export formats
- All critical API issues from review addressed
2026-02-16 14:00:09 +05:00
Shivam Shahi– oss/acc 669a05892b Merge branch 'main' into feat/zoho-crm 2026-02-15 21:47:52 +05:30
IamSayeed 4898a9759a Merge branch 'main' into feature/add-asana-integration 2026-02-15 13:07:15 +05:30
Sayeed Rizwan 2c2fa25580 fix: Resolve merge conflicts in credential and tool registries 2026-02-15 13:00:23 +05:30
Sayeed Rizwan 56496d7dbd feat: Add Asana integration for project management automation
- Implement 25 MCP tools for comprehensive Asana operations
  - Task management (create, update, search, delete, complete, comment, subtask)
  - Project management (create, update, list, get tasks)
  - Workspace & team operations (list workspaces, get users)
  - Section management for Kanban workflows
  - Tag and custom field support

- Add Personal Access Token (PAT) authentication
- Use official asana>=3.2.0 Python SDK (v5+ API)
- Include comprehensive error handling with ApiException
- Add 5 unit tests with 100% pass rate
- Provide detailed documentation and usage examples

Technical Details:
- Uses asana.ApiClient with Configuration pattern
- Implements workspace resolution by name or GID
- Handles paginated responses automatically
- Follows CredentialStoreAdapter pattern
- Matches existing tool structure (slack_tool, github_tool)

Closes #4156
2026-02-15 11:33:17 +05:30
y0sif dd0696e44d chore: resolve merge conflicts with main 2026-02-14 21:38:44 +02:00
y0sif dcda273e0b chore: resolve merge conflicts with main 2026-02-14 21:32:33 +02:00
y0sif f3b159c650 docs(tools): document Attio CRM in README 2026-02-14 21:23:47 +02:00
y0sif 06df037e28 chore: add Attio credentials to test spec file 2026-02-14 21:22:55 +02:00
y0sif e814e516d1 chore: add Attio credentials to init file 2026-02-14 21:21:37 +02:00
y0sif 0375e068ed test(tools): add Attio tool tests 2026-02-14 21:20:03 +02:00
y0sif 34ffc533d3 feat(tools): add Attio CRM integration 2026-02-14 21:19:14 +02:00
mubarakar95 ea2ea1a4ae Merge branch 'main' into integration/apify 2026-02-14 17:53:39 +05:30
mubarakar95 9e11947687 style: apply ruff formatting to apify_tool.py 2026-02-14 17:22:35 +05:30
mubarakar95 47117281e1 fix(test): resolve E501 line too long in test_apify_tool.py 2026-02-14 17:22:33 +05:30
mubarakar95 032dd13f5a feat(tools): implement Apify integration with 4 tools and comprehensive tests
- Added credential spec with health check endpoint
- Implemented apify_run_actor (sync/async execution)
- Implemented apify_get_dataset (result retrieval)
- Implemented apify_get_run (status checking)
- Implemented apify_search_actors (marketplace search)
- Created comprehensive README with examples and use cases
- Added 24 unit tests with mocked API responses
- All tests passing, conformance validated, linting clean

Resolves: #4510
2026-02-14 17:22:25 +05:30
mubarakar95 13d8ebbeff feat: Add Apify integration (issue #4510)
Implements comprehensive Apify integration for web scraping and automation:

- Added 4 new tools: apify_run_actor, apify_get_dataset, apify_get_run, apify_search_actors
- Credential management for APIFY_API_TOKEN with health check
- Support for synchronous (wait=True) and asynchronous (wait=False) actor execution
- Actor ID validation and comprehensive error handling
- Full test coverage (26 tests passing)
- README with usage examples and documentation

Addresses #4510
2026-02-14 11:53:56 +05:30
Shivam Shahi– oss/acc 2efa0e01df ruff format fix 2026-02-14 00:35:30 +05:30
Shivam Shahi– oss/acc 6044369fdf feat(tools): add Zoho CRM v8 integration with OAuth2 and MCP tools
Add Zoho CRM MCP integration for lead/contact/account/deal workflows with notes support. Implements 5 MCP tools:
- zoho_crm_search: Search Leads/Contacts/Accounts/Deals by criteria or word with pagination
- zoho_crm_get_record: Fetch a single record by module and ID
- zoho_crm_create_record: Create records with pass-through field payloads
- zoho_crm_update_record: Update records by ID with partial field payloads
- zoho_crm_add_note: Create notes linked to CRM records via Parent_Id mapping

Features:
- Zoho OAuth2 provider added in core credentials (refresh-token flow)
- Zoho auth format: Authorization: Zoho-oauthtoken <token>
- Region/DC-aware routing using accounts domain/region + api_domain usage
- Persisted DC metadata on refresh (api_domain/accounts_domain/location)
- Credential spec and health check registration for zoho_crm
- Tool registration and allowed-tool list updates
- Normalized tool responses with retriable 429 handling
- README with setup, auth modes, usage, and testing instructions
- Comprehensive unit/integration coverage updates for tool, provider, and health checks

Validation:
- Scoped ruff lint/format checks passed
- Targeted test suite passed: 563 passed, 18 skipped

Closes #4418
2026-02-13 18:28:12 +05:30
RichardTang-Aden 97440f9e8a Merge branch 'main' into feature/x-twitter-integration 2026-02-11 17:13:33 -08:00
Your hh3538962 765f7cae58 feat(tools): add get_datasets, get_reports, and export_report functions to Power BI integration 2026-02-11 22:19:51 +05:00
Your hh3538962 b455c8a2ad Merge remote-tracking branch 'origin/main' into feat/power-bi-integration 2026-02-11 22:07:00 +05:00
Sapna vishnoi da25e0ffa5 Merge branch 'main' into feat/redshift-integration 2026-02-11 13:42:26 +05:30
Your hh3538962 e07703c01f feat(tools): add Power BI integration - initial structure with workspace and dataset refresh functions 2026-02-10 13:23:32 +05:00
mishrapravin114 a4abf3eb2b Merge upstream/main: resolve conflicts with Apollo integration
- Keep both APOLLO_CREDENTIALS and AIRTABLE_CREDENTIALS
- Keep both apollo_tool and airtable_tool imports (alphabetical)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:25:17 +05:30
mishrapravin114 269d72d073 Merge upstream/main: resolve conflicts with Apollo integration
- Keep both APOLLO_CREDENTIALS and CALENDLY_CREDENTIALS
- Keep both apollo_tool and calendly_tool imports (alphabetical)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:20:17 +05:30
mishrapravin114 c8f5dccbd2 docs(airtable): add rate limit section to README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:17:49 +05:30
mishrapravin114 8b797ee73f feat(airtable): add rate limit retry and retry_after
- Add 429 handling with retry_after from Retry-After header
- Add _request_with_retry (2 retries) for all API calls
- Update tests to use httpx.request

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:17:37 +05:30
mishrapravin114 de38adb1e4 feat(calendly): add rate limit handling, retry, 7-day validation
- Add 429 handling with retry_after from Retry-After header
- Add _request_with_retry (2 retries) for all API calls
- Validate get_availability date range <= 7 days
- Update tests to use httpx.request

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:16:37 +05:30
Sapna vishnoi c169bcc5d8 Merge branch 'main' into feat/redshift-integration 2026-02-09 23:32:08 +05:30
kubrakaradirek 80ea286beb fix: resolve complex merge conflicts and restore integrations 2026-02-09 16:09:43 +03:00
kubrakaradirek 3499be782e feat: implement MSSQL tool with schema discovery closes #3377 2026-02-09 15:32:57 +03:00
Gordon Ng 16603ae49c Test MCP 2026-02-09 01:48:49 -05:00
Gordon Ng bf6bd9ce7f test mcp 2026-02-09 01:48:46 -05:00
Gordon Ng a54c0f6f46 update 2026-02-09 01:20:25 -05:00
Gordon Ng beeed11d48 update 2026-02-09 01:11:33 -05:00
Manas Dutta 25331590a7 feat(reddit): add Reddit health checker and update tool functions 2026-02-08 19:26:01 +05:30
GastonAQS bff9f8976e Merge branch 'main' into feature/add-trello-integration 2026-02-07 15:57:48 -03:00
Manas Dutta b71628e211 Merge branch 'main' into feature/reddit-integration 2026-02-07 19:35:02 +05:30
Manas Dutta 8c1cb1f55b feat: add Reddit integration with 18 MCP tools
Implements Reddit API integration for community management and content monitoring.

Features:
- Search & Monitoring: search posts/comments, get subreddit feeds (new/hot), get posts/comments (6 tools)
- Content Creation: submit posts, reply, edit, delete comments (5 tools)
- User Engagement: get profiles, upvote, downvote, save posts (4 tools)
- Moderation: remove/approve posts, ban users (3 tools)

Implementation:
- OAuth 2.0 authentication via REDDIT_CREDENTIALS
- PRAW library for Reddit API integration
- Comprehensive error handling and validation
- Full test coverage (25 tests passing)

Resolves #3595
2026-02-07 18:38:59 +05:30
mishrapravin114 66214384a9 fix: add register_airtable import and fix ruff I001 import order
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:18:26 +05:30
mishrapravin114 6d6646887c feat(tools): add Airtable bases and records integration
- Add Airtable tool with 5 MCP tools:
  - airtable_list_bases
  - airtable_list_tables
  - airtable_list_records (with filter/sort)
  - airtable_create_record
  - airtable_update_record
- Add AIRTABLE_CREDENTIALS with credentialSpec + credentialStore
- Add AirtableHealthChecker for token validation
- Add README with setup and usage
- Add unit tests (9 tests total)

Fixes #2911

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:14:46 +05:30
mishrapravin114 6f8db0ed08 style: apply ruff format to calendly and health check files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:00:05 +05:30
mishrapravin114 6aaf6836ea fix(calendly): resolve ruff lint errors (UP017, E501)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 16:58:48 +05:30
mishrapravin114 4f2348f50e feat(tools): add Calendly scheduling integration
- Add Calendly tool with 4 MCP tools:
  - calendly_list_event_types
  - calendly_get_availability
  - calendly_get_booking_link
  - calendly_cancel_event
- Add CALENDLY_CREDENTIALS with credentialSpec + credentialStore
- Add CalendlyHealthChecker for token validation
- Add README with setup and usage
- Add unit tests (12 tests total)

Fixes #2930

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 16:51:27 +05:30
RichardTang-Aden deb7f2f72a Merge pull request #3814 from Amdev-5/feature/x-twitter-integration
fix(tests): update credential group test for X integration
2026-02-06 09:16:42 -08:00
Amdev-5 d989d9c65a fix(tests): update credential group test for X integration
Add test_x_credentials_share_credential_group to verify all X credentials
share the 'x' credential group. Update test_credential_group_default_empty
to account for X credentials alongside existing Google exceptions.
2026-02-06 22:17:40 +05:30
bryan 4173c606ab Merge feature/x-twitter-final-integration from Amdev-5/hive - X (Twitter) tool with DM support 2026-02-06 08:03:43 -08:00
Amdev-5 a01430d20f Merge verification fixes into PR branch 2026-02-06 16:42:56 +05:30
Amdev-5 2a8f775732 feat(tools): enhance X tool with DM support and robust error handling
- Added `x_send_dm` tool using v2 endpoint (`POST /dm_conversations/with/:id/messages`) for reliable 1:1 messaging.
- Fixed 403 Forbidden payload validation errors by simplifying DM payload structure.
- Enhanced `_handle_response` to verify `x_tool.py` returns raw API error details for 403/400 responses, aiding in permission debugging.
- Updated `demo_x_tools.py` to support standard `.env` variable names (e.g., `X_API_KEY`) and added user lookup for DM testing.
- Added unit tests covering new DM functionality and payload verification in `test_x_tool.py`.
- Audited credential handling: Read-only tools (Search/Mentions) correctly use Bearer Token, while Write tools (Post/Reply/Delete/DM) enforce OAuth 1.0a User Context.

Verified with live API tests (see PR description for logs).
2026-02-06 15:48:20 +05:30
Sapna vishnoi 4a0d9b2855 Merge branch 'main' into feat/redshift-integration 2026-02-05 11:44:09 +05:30
y0sif 92c65d69ea chore: resolve merge conflicts with main 2026-02-05 07:13:36 +02:00
Yosif Soliman 910a8968c4 fix(linear): correct GraphQL variable type for workflow states query 2026-02-05 07:00:28 +02:00
Sapna vishnoi cdb4679c5a Merge branch 'main' into feat/redshift-integration 2026-02-05 00:05:38 +05:30
Sapna.Vishnoi 1a9dce89b4 feat(tools): Add Amazon Redshift integration
- Implement 5 core functions for data warehouse querying
- Add boto3 integration with Redshift Data API
- Security: Read-only SELECT queries by default
- Full credential store support
- 26/26 tests passing (100% coverage)
- Complete documentation with examples
2026-02-04 23:58:35 +05:30
Aneesh cf1e4d7f88 Merge remote-tracking branch 'origin/main' into feature/youtube-transcript 2026-02-04 19:46:52 +05:30
Aneesh f2f0b4fc61 feat(tools): add youtube transcript integration via youtube-transcript-api 2026-02-04 19:24:40 +05:30
y0sif b21dd25181 fix(linear): handle credential decryption errors gracefully, handle mcp tool issue with credentials 2026-02-04 05:21:23 +02:00
y0sif 04a18bcbe5 docs(tools): document Linear integration in README and setup credentials claude skill 2026-02-04 04:05:15 +02:00
y0sif 7f66dd67eb feat(linear): add OAuth setup instructions 2026-02-04 04:03:37 +02:00
y0sif cfa03b89c8 test(tools): add comprehensive Linear tool tests 2026-02-04 03:47:28 +02:00
y0sif 9866d7a22b feat(tools): add Linear project management integration 2026-02-04 03:47:03 +02:00
GastonAQS 331a6e442f feat: add Trello integration tools and API client 2026-02-03 10:32:25 -03:00
Sashank Thapa 1c2295b2b5 Merge branch 'adenhq:main' into feature/twitter-x-mcp-tool 2026-02-03 16:20:45 +05:30
Sashank Thapa fa43ca3785 Merge branch 'adenhq:main' into feature/twitter-x-mcp-tool 2026-01-31 16:26:39 +05:30
kozuedoingregression b4a2c3bd14 ruff formatting and lint fixes 2026-01-31 16:18:16 +05:30
kozuedoingregression 2d4ec4f462 lint fix 2026-01-31 16:14:25 +05:30
kozuedoingregression 1e8b933da0 add X (Twitter) integration tool 2026-01-31 15:49:16 +05:30
Aneesh 48b1e0e038 Docs: clarify agent creation assumptions in Getting Started 2026-01-28 22:49:30 +05:30
424 changed files with 57622 additions and 2546 deletions
@@ -0,0 +1,89 @@
name: Integration Bounty
description: A bounty task for the integration contribution program
title: "[Bounty]: "
labels: []
body:
- type: markdown
attributes:
value: |
## Integration Bounty
This issue is part of the [Integration Bounty Program](../../docs/bounty-program/README.md).
**Claim this bounty** by commenting below — a maintainer will assign you within 24 hours.
- type: dropdown
id: bounty-type
attributes:
label: Bounty Type
options:
- "Test a Tool (20 pts)"
- "Write Docs (20 pts)"
- "Code Contribution (30 pts)"
- "New Integration (75 pts)"
validations:
required: true
- type: dropdown
id: difficulty
attributes:
label: Difficulty
options:
- Easy
- Medium
- Hard
validations:
required: true
- type: input
id: tool-name
attributes:
label: Tool Name
description: The integration this bounty targets (e.g., `airtable`, `salesforce`)
placeholder: e.g., airtable
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: What needs to be done to complete this bounty.
placeholder: |
Describe the specific task, including:
- What the contributor needs to do
- Links to relevant files in the repo
- Any setup requirements (API keys, accounts, etc.)
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance Criteria
description: What "done" looks like. The PR or report must meet all criteria.
placeholder: |
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] CI passes
validations:
required: true
- type: textarea
id: relevant-files
attributes:
label: Relevant Files
description: Links to tool directory, credential spec, health check file, etc.
placeholder: |
- Tool: `tools/src/aden_tools/tools/{tool_name}/`
- Credential spec: `tools/src/aden_tools/credentials/{category}.py`
- Health checks: `tools/src/aden_tools/credentials/health_check.py`
- type: textarea
id: resources
attributes:
label: Resources
description: Links to API docs, examples, or guides that will help the contributor.
placeholder: |
- [Building Tools Guide](../../tools/BUILDING_TOOLS.md)
- [Tool README Template](../../docs/bounty-program/templates/tool-readme-template.md)
- API docs: https://...
+31
View File
@@ -0,0 +1,31 @@
name: Link Discord Account
description: Connect your GitHub and Discord for the bounty program
title: "link: @{{ github.actor }}"
labels: ["link-discord"]
body:
- type: markdown
attributes:
value: |
Link your Discord account to receive XP and role rewards when your bounty PRs are merged.
**How to find your Discord ID:**
1. Open Discord Settings > Advanced > Enable **Developer Mode**
2. Right-click your username > **Copy User ID**
- type: input
id: discord_id
attributes:
label: Discord User ID
description: "Your numeric Discord ID (not your username). Example: 123456789012345678"
placeholder: "123456789012345678"
validations:
required: true
- type: input
id: display_name
attributes:
label: Display Name (optional)
description: How you'd like to be credited
placeholder: "Jane Doe"
validations:
required: false
+37
View File
@@ -0,0 +1,37 @@
name: Bounty completed
description: Awards points and notifies Discord when a bounty PR is merged
on:
pull_request:
types: [closed]
jobs:
bounty-notify:
if: >
github.event.pull_request.merged == true &&
contains(join(github.event.pull_request.labels.*.name, ','), 'bounty:')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Award XP and notify Discord
run: bun run scripts/bounty-tracker.ts notify
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_BOUNTY_WEBHOOK_URL }}
LURKR_API_KEY: ${{ secrets.LURKR_API_KEY }}
LURKR_GUILD_ID: ${{ secrets.LURKR_GUILD_ID }}
PR_NUMBER: ${{ github.event.pull_request.number }}
+5 -2
View File
@@ -62,8 +62,11 @@ jobs:
uv run pytest tests/ -v
test-tools:
name: Test Tools
runs-on: ubuntu-latest
name: Test Tools (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
+126
View File
@@ -0,0 +1,126 @@
name: Link Discord account
description: Auto-creates a PR to add contributor to contributors.yml when a link-discord issue is opened
on:
issues:
types: [opened]
jobs:
link-discord:
if: contains(github.event.issue.labels.*.name, 'link-discord')
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: write
issues: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Parse issue and update contributors.yml
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const issue = context.payload.issue;
const githubUsername = issue.user.login;
// Parse the issue body for form fields
const body = issue.body || '';
// Extract Discord ID — look for the numeric value after the "Discord User ID" heading
const discordMatch = body.match(/### Discord User ID\s*\n\s*(\d{17,20})/);
if (!discordMatch) {
await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: `Could not find a valid Discord ID in the issue body. Please make sure you entered a numeric ID (17-20 digits), not a username.\n\nExample: \`123456789012345678\``
});
await github.rest.issues.update({
...context.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
});
return;
}
const discordId = discordMatch[1];
// Extract display name (optional)
const nameMatch = body.match(/### Display Name \(optional\)\s*\n\s*(.+)/);
const displayName = nameMatch ? nameMatch[1].trim() : '';
// Check if user already exists
const yml = fs.readFileSync('contributors.yml', 'utf-8');
if (yml.includes(`github: ${githubUsername}`)) {
await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: `@${githubUsername} is already in \`contributors.yml\`. If you need to update your Discord ID, please edit the file directly via PR.`
});
await github.rest.issues.update({
...context.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'completed'
});
return;
}
// Append entry to contributors.yml
let entry = ` - github: ${githubUsername}\n discord: "${discordId}"`;
if (displayName && displayName !== '_No response_') {
entry += `\n name: ${displayName}`;
}
entry += '\n';
const updated = yml.trimEnd() + '\n' + entry;
fs.writeFileSync('contributors.yml', updated);
// Set outputs for commit step
core.exportVariable('GITHUB_USERNAME', githubUsername);
core.exportVariable('DISCORD_ID', discordId);
core.exportVariable('ISSUE_NUMBER', issue.number.toString());
- name: Create PR
run: |
# Check if there are changes
if git diff --quiet contributors.yml; then
echo "No changes to contributors.yml"
exit 0
fi
BRANCH="docs/link-discord-${GITHUB_USERNAME}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add contributors.yml
git commit -m "docs: link @${GITHUB_USERNAME} to Discord"
git push origin "$BRANCH"
gh pr create \
--title "docs: link @${GITHUB_USERNAME} to Discord" \
--body "Adds @${GITHUB_USERNAME} (Discord \`${DISCORD_ID}\`) to \`contributors.yml\` for bounty XP tracking.
Closes #${ISSUE_NUMBER}" \
--base main \
--head "$BRANCH" \
--label "link-discord"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Notify on issue
uses: actions/github-script@v7
with:
script: |
const username = process.env.GITHUB_USERNAME;
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
await github.rest.issues.createComment({
...context.repo,
issue_number: issueNumber,
body: `A PR has been created to link your account. A maintainer will merge it shortly — once merged, you'll receive XP and Discord pings when your bounty PRs are merged.`
});
+40
View File
@@ -0,0 +1,40 @@
name: Weekly bounty leaderboard
description: Posts the integration bounty leaderboard to Discord every Monday
on:
schedule:
# Every Monday at 9:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:
inputs:
since_date:
description: "Only count PRs merged after this date (YYYY-MM-DD). Leave empty for all-time."
required: false
jobs:
leaderboard:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Post leaderboard to Discord
run: bun run scripts/bounty-tracker.ts leaderboard
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_BOUNTY_WEBHOOK_URL }}
LURKR_API_KEY: ${{ secrets.LURKR_API_KEY }}
LURKR_GUILD_ID: ${{ secrets.LURKR_GUILD_ID }}
SINCE_DATE: ${{ github.event.inputs.since_date || '' }}
+1
View File
@@ -79,3 +79,4 @@ core/tests/*dumps/*
screenshots/*
.gemini/*
+4
View File
@@ -2,6 +2,10 @@
Shared agent instructions for this workspace.
## Deprecations
- **TUI is deprecated.** The terminal UI (`hive tui`) is no longer maintained. Use the browser-based interface (`hive open`) instead.
## Coding Agent Notes
-
+13 -1
View File
@@ -20,8 +20,20 @@ check: ## Run all checks without modifying files (CI-safe)
cd core && ruff format --check .
cd tools && ruff format --check .
test: ## Run all tests
test: ## Run all tests (core + tools, excludes live)
cd core && uv run python -m pytest tests/ -v
cd tools && uv run python -m pytest -v
test-tools: ## Run tool tests only (mocked, no credentials needed)
cd tools && uv run python -m pytest -v
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 tools && uv run python -m pytest -v
cd tools && uv run python -m pytest -m live -s -o "addopts=" --log-cli-level=INFO
install-hooks: ## Install pre-commit hooks
uv pip install pre-commit
+3
View File
@@ -82,6 +82,7 @@ Use Hive when you need:
- Python 3.11+ for agent development
- An LLM provider that powers the agents
- **ripgrep (optional, recommended on Windows):** The `search_files` tool uses ripgrep for faster file search. If not installed, a Python fallback is used. On Windows: `winget install BurntSushi.ripgrep` or `scoop install ripgrep`
> **Note for Windows Users:** It is strongly recommended to use **WSL (Windows Subsystem for Linux)** or **Git Bash** to run this framework. Some core automation scripts may not execute correctly in standard Command Prompt or PowerShell.
@@ -112,6 +113,8 @@ This sets up:
- At last, it will initiate the open hive interface in your browser
> **Tip:** To reopen the dashboard later, run `hive open` from the project directory.
<img width="2500" height="1214" alt="home-screen" src="https://github.com/user-attachments/assets/134d897f-5e75-4874-b00b-e0505f6b45c4" />
### Build Your First Agent
+31
View File
@@ -0,0 +1,31 @@
perf: reduce subprocess spawning in quickstart scripts (#4427)
## Problem
Windows process creation (CreateProcess) is 10-100x slower than Linux fork/exec.
The quickstart scripts were spawning 4+ separate `uv run python -c "import X"`
processes to verify imports, adding ~600ms overhead on Windows.
## Solution
Consolidated all import checks into a single batch script that checks multiple
modules in one subprocess call, reducing spawn overhead by ~75%.
## Changes
- **New**: `scripts/check_requirements.py` - Batched import checker
- **New**: `scripts/test_check_requirements.py` - Test suite
- **New**: `scripts/benchmark_quickstart.ps1` - Performance benchmark tool
- **Modified**: `quickstart.ps1` - Updated import verification (2 sections)
- **Modified**: `quickstart.sh` - Updated import verification
## Performance Impact
**Benchmark results on Windows:**
- Before: ~19.8 seconds for import checks
- After: ~4.9 seconds for import checks
- **Improvement: 14.9 seconds saved (75.2% faster)**
## Testing
- ✅ All functional tests pass (`scripts/test_check_requirements.py`)
- ✅ Quickstart scripts work correctly on Windows
- ✅ Error handling verified (invalid imports reported correctly)
- ✅ Performance benchmark confirms 75%+ improvement
Fixes #4427
+27
View File
@@ -0,0 +1,27 @@
# Identity mapping: GitHub username -> Discord ID
#
# This file links GitHub accounts to Discord accounts for the
# Integration Bounty Program. When a bounty PR is merged, the
# GitHub Action uses this file to ping the contributor on Discord.
#
# HOW TO ADD YOURSELF:
# Open a "Link Discord Account" issue:
# https://github.com/aden-hive/hive/issues/new?template=link-discord.yml
# A GitHub Action will automatically add your entry here.
#
# To find your Discord ID:
# 1. Open Discord Settings > Advanced > Enable Developer Mode
# 2. Right-click your name > Copy User ID
#
# Format:
# - github: your-github-username
# discord: "your-discord-id" # quotes required (it's a number)
# name: Your Display Name # optional
contributors:
# - github: example-user
# discord: "123456789012345678"
# name: Example User
- github: TimothyZhang7
discord: "408460790061072384"
name: Timothy@Aden
+2 -2
View File
@@ -10,7 +10,7 @@ def _load_preferred_model() -> str:
config_path = Path.home() / ".hive" / "configuration.json"
if config_path.exists():
try:
with open(config_path) as f:
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
llm = config.get("llm", {})
if llm.get("provider") and llm.get("model"):
@@ -24,7 +24,7 @@ def _load_preferred_model() -> str:
class RuntimeConfig:
model: str = field(default_factory=_load_preferred_model)
temperature: float = 0.7
max_tokens: int = 40000
max_tokens: int = 8000
api_key: str | None = None
api_base: str | None = None
@@ -7,11 +7,11 @@ from framework.graph import NodeSpec
# Load reference docs at import time so they're always in the system prompt.
# No voluntary read_file() calls needed — the LLM gets everything upfront.
_ref_dir = Path(__file__).parent.parent / "reference"
_framework_guide = (_ref_dir / "framework_guide.md").read_text()
_file_templates = (_ref_dir / "file_templates.md").read_text()
_anti_patterns = (_ref_dir / "anti_patterns.md").read_text()
_framework_guide = (_ref_dir / "framework_guide.md").read_text(encoding="utf-8")
_file_templates = (_ref_dir / "file_templates.md").read_text(encoding="utf-8")
_anti_patterns = (_ref_dir / "anti_patterns.md").read_text(encoding="utf-8")
_gcu_guide_path = _ref_dir / "gcu_guide.md"
_gcu_guide = _gcu_guide_path.read_text() if _gcu_guide_path.exists() else ""
_gcu_guide = _gcu_guide_path.read_text(encoding="utf-8") if _gcu_guide_path.exists() else ""
def _is_gcu_enabled() -> bool:
@@ -46,6 +46,7 @@ _SHARED_TOOLS = [
"read_file",
"write_file",
"edit_file",
"hashline_edit",
"list_directory",
"search_files",
"run_command",
@@ -55,8 +56,6 @@ _SHARED_TOOLS = [
"validate_agent_tools",
"list_agents",
"list_agent_sessions",
"get_agent_session_state",
"get_agent_session_memory",
"list_agent_checkpoints",
"get_agent_checkpoint",
"run_agent_tests",
@@ -131,12 +130,23 @@ errors yourself. Don't declare success until validation passes.
# Tools
## Paths (MANDATORY)
**Always use RELATIVE paths**
(e.g. `exports/agent_name/config.py`, `exports/agent_name/nodes/__init__.py`).
**Never use absolute paths** like `/mnt/data/...` or `/workspace/...` they fail.
The project root is implicit.
## File I/O
- read_file(path, offset?, limit?) read with line numbers
- read_file(path, offset?, limit?, hashline?) read with line numbers; \
hashline=True for N:hhhh|content anchors (use with hashline_edit)
- write_file(path, content) create/overwrite, auto-mkdir
- edit_file(path, old_text, new_text, replace_all?) fuzzy-match edit
- hashline_edit(path, edits, auto_cleanup?, encoding?) anchor-based \
editing using N:hhhh refs from read_file(hashline=True). Ops: set_line, \
replace_lines, insert_after, insert_before, replace, append
- list_directory(path, recursive?) list contents
- search_files(pattern, path?, include?) regex search
- search_files(pattern, path?, include?, hashline?) regex search; \
hashline=True for anchors in results
- run_command(command, cwd?, timeout?) shell execution
- undo_changes(path?) restore from git snapshot
@@ -149,8 +159,6 @@ available tools grouped by category. output_schema: "simple" (default) or \
in an agent's nodes actually exist. Call after building.
- list_agents() list all agent packages in exports/ with session counts
- list_agent_sessions(agent_name, status?, limit?) list sessions
- get_agent_session_state(agent_name, session_id) full session state
- get_agent_session_memory(agent_name, session_id, key?) memory data
- list_agent_checkpoints(agent_name, session_id) list checkpoints
- get_agent_checkpoint(agent_name, session_id, checkpoint_id?) load checkpoint
- run_agent_tests(agent_name, test_types?, fail_fast?) run pytest with parsing
@@ -185,8 +193,7 @@ After writing agent code, validate structurally AND run tests:
## Debugging Built Agents
When a user says "my agent is failing" or "debug this agent":
1. list_agent_sessions("{agent_name}") find the session
2. get_agent_session_state("{agent_name}", "{session_id}") see status
3. get_agent_session_memory("{agent_name}", "{session_id}") inspect data
2. get_worker_status
4. list_agent_checkpoints / get_agent_checkpoint trace execution
# Agent Building Workflow
@@ -608,7 +615,7 @@ You have full coding tools for building and modifying agents:
- File I/O: read_file, write_file, edit_file, list_directory, search_files, \
run_command, undo_changes
- Meta-agent: list_agent_tools, validate_agent_tools, \
list_agents, list_agent_sessions, get_agent_session_state, get_agent_session_memory, \
list_agents, list_agent_sessions, \
list_agent_checkpoints, get_agent_checkpoint, run_agent_tests
- load_built_agent(agent_path) Load the agent and switch to STAGING mode
- list_credentials(credential_id?) List authorized credentials
+2 -2
View File
@@ -660,7 +660,7 @@ class GraphBuilder:
# Generate Python code
code = self._generate_code(graph)
Path(path).write_text(code)
Path(path).write_text(code, encoding="utf-8")
self.session.phase = BuildPhase.EXPORTED
self._save_session()
@@ -754,7 +754,7 @@ class GraphBuilder:
"""Save session to disk."""
self.session.updated_at = datetime.now()
path = self.storage_path / f"{self.session.id}.json"
path.write_text(self.session.model_dump_json(indent=2))
path.write_text(self.session.model_dump_json(indent=2), encoding="utf-8")
def _load_session(self, session_id: str) -> BuildSession:
"""Load session from disk."""
+9 -2
View File
@@ -6,6 +6,7 @@ helper functions.
"""
import json
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
@@ -18,6 +19,7 @@ from framework.graph.edge import DEFAULT_MAX_TOKENS
# ---------------------------------------------------------------------------
HIVE_CONFIG_FILE = Path.home() / ".hive" / "configuration.json"
logger = logging.getLogger(__name__)
def get_hive_config() -> dict[str, Any]:
@@ -27,7 +29,12 @@ def get_hive_config() -> dict[str, Any]:
try:
with open(HIVE_CONFIG_FILE, encoding="utf-8-sig") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
except (json.JSONDecodeError, OSError) as e:
logger.warning(
"Failed to load Hive config %s: %s",
HIVE_CONFIG_FILE,
e,
)
return {}
@@ -92,7 +99,7 @@ def get_api_key() -> str | None:
def get_gcu_enabled() -> bool:
"""Return whether GCU (browser automation) is enabled in user config."""
return get_hive_config().get("gcu_enabled", False)
return get_hive_config().get("gcu_enabled", True)
def get_api_base() -> str | None:
+14 -7
View File
@@ -37,6 +37,8 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
import json as _json
import httpx
logger = logging.getLogger(__name__)
@@ -260,6 +262,11 @@ class AdenCredentialClient:
self.config = config
self._client: httpx.Client | None = None
@staticmethod
def _parse_json(response: httpx.Response) -> Any:
"""Parse JSON from response, tolerating UTF-8 BOM."""
return _json.loads(response.content.decode("utf-8-sig"))
def _get_client(self) -> httpx.Client:
if self._client is None:
headers = {
@@ -295,7 +302,7 @@ class AdenCredentialClient:
raise AdenAuthenticationError("Agent API key is invalid or revoked")
if response.status_code == 403:
data = response.json()
data = self._parse_json(response)
raise AdenClientError(data.get("message", "Forbidden"))
if response.status_code == 404:
@@ -309,7 +316,7 @@ class AdenCredentialClient:
)
if response.status_code == 400:
data = response.json()
data = self._parse_json(response)
msg = data.get("message", "Bad request")
if data.get("error") == "refresh_failed" or "refresh" in msg.lower():
raise AdenRefreshError(
@@ -356,7 +363,7 @@ class AdenCredentialClient:
alias, status, email, expires_at.
"""
response = self._request_with_retry("GET", "/v1/credentials")
data = response.json()
data = self._parse_json(response)
return [AdenIntegrationInfo.from_dict(item) for item in data.get("integrations", [])]
# Alias
@@ -376,7 +383,7 @@ class AdenCredentialClient:
"""
try:
response = self._request_with_retry("GET", f"/v1/credentials/{integration_id}")
data = response.json()
data = self._parse_json(response)
return AdenCredentialResponse.from_dict(data, integration_id=integration_id)
except AdenNotFoundError:
return None
@@ -394,7 +401,7 @@ class AdenCredentialClient:
AdenCredentialResponse with new access_token.
"""
response = self._request_with_retry("POST", f"/v1/credentials/{integration_id}/refresh")
data = response.json()
data = self._parse_json(response)
return AdenCredentialResponse.from_dict(data, integration_id=integration_id)
def validate_token(self, integration_id: str) -> dict[str, Any]:
@@ -407,7 +414,7 @@ class AdenCredentialClient:
{"valid": bool, "status": str, "expires_at": str, "error": str|null}
"""
response = self._request_with_retry("GET", f"/v1/credentials/{integration_id}/validate")
return response.json()
return self._parse_json(response)
def health_check(self) -> dict[str, Any]:
"""Check Aden server health."""
@@ -415,7 +422,7 @@ class AdenCredentialClient:
client = self._get_client()
response = client.get("/health")
if response.status_code == 200:
data = response.json()
data = self._parse_json(response)
data["latency_ms"] = response.elapsed.total_seconds() * 1000
return data
return {"status": "degraded", "error": f"HTTP {response.status_code}"}
+1 -1
View File
@@ -69,7 +69,7 @@ def save_credential_key(key: str) -> Path:
# Restrict the secrets directory itself
path.parent.chmod(stat.S_IRWXU) # 0o700
path.write_text(key)
path.write_text(key, encoding="utf-8")
path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
os.environ[CREDENTIAL_KEY_ENV_VAR] = key
@@ -73,6 +73,7 @@ from .provider import (
TokenExpiredError,
TokenPlacement,
)
from .zoho_provider import ZohoOAuth2Provider
__all__ = [
# Types
@@ -82,6 +83,7 @@ __all__ = [
# Providers
"BaseOAuth2Provider",
"HubSpotOAuth2Provider",
"ZohoOAuth2Provider",
# Lifecycle
"TokenLifecycleManager",
"TokenRefreshResult",
@@ -0,0 +1,198 @@
"""
Zoho CRM-specific OAuth2 provider.
Pre-configured for Zoho's OAuth2 endpoints and CRM scopes.
Extends BaseOAuth2Provider for Zoho-specific behavior.
Usage:
provider = ZohoOAuth2Provider(
client_id="your-client-id",
client_secret="your-client-secret",
accounts_domain="https://accounts.zoho.com", # or .in, .eu, etc.
)
# Use with credential store
store = CredentialStore(
storage=EncryptedFileStorage(),
providers=[provider],
)
See: https://www.zoho.com/crm/developer/docs/api/v2/access-refresh.html
"""
from __future__ import annotations
import logging
import os
from typing import Any
from ..models import CredentialObject, CredentialRefreshError, CredentialType
from .base_provider import BaseOAuth2Provider
from .provider import OAuth2Config, OAuth2Token, TokenPlacement
logger = logging.getLogger(__name__)
# Default CRM scopes for Phase 1 (Leads, Contacts, Accounts, Deals, Notes)
ZOHO_DEFAULT_SCOPES = [
"ZohoCRM.modules.leads.ALL",
"ZohoCRM.modules.contacts.ALL",
"ZohoCRM.modules.accounts.ALL",
"ZohoCRM.modules.deals.ALL",
"ZohoCRM.modules.notes.CREATE",
]
class ZohoOAuth2Provider(BaseOAuth2Provider):
"""
Zoho CRM OAuth2 provider with pre-configured endpoints.
Handles Zoho-specific OAuth2 behavior:
- Pre-configured token and authorization URLs (region-aware)
- Default CRM scopes for Leads, Contacts, Accounts, Deals, Notes
- Token validation via Zoho CRM API
- Authorization header format: "Authorization: Zoho-oauthtoken {token}"
Example:
provider = ZohoOAuth2Provider(
client_id="your-zoho-client-id",
client_secret="your-zoho-client-secret",
accounts_domain="https://accounts.zoho.com", # US
# or "https://accounts.zoho.in" for India
# or "https://accounts.zoho.eu" for EU
)
"""
def __init__(
self,
client_id: str,
client_secret: str,
accounts_domain: str = "https://accounts.zoho.com",
api_domain: str | None = None,
scopes: list[str] | None = None,
):
"""
Initialize Zoho OAuth2 provider.
Args:
client_id: Zoho OAuth2 client ID
client_secret: Zoho OAuth2 client secret
accounts_domain: Zoho accounts domain (region-specific)
- US: https://accounts.zoho.com
- India: https://accounts.zoho.in
- EU: https://accounts.zoho.eu
- etc.
api_domain: Zoho API domain for CRM calls (used in validate).
Defaults to ZOHO_API_DOMAIN env or https://www.zohoapis.com
scopes: Override default scopes if needed
"""
base = accounts_domain.rstrip("/")
token_url = f"{base}/oauth/v2/token"
auth_url = f"{base}/oauth/v2/auth"
config = OAuth2Config(
token_url=token_url,
authorization_url=auth_url,
client_id=client_id,
client_secret=client_secret,
default_scopes=scopes or ZOHO_DEFAULT_SCOPES,
token_placement=TokenPlacement.HEADER_CUSTOM,
custom_header_name="Authorization",
)
super().__init__(config, provider_id="zoho_crm_oauth2")
self._accounts_domain = base
self._api_domain = (
api_domain or os.getenv("ZOHO_API_DOMAIN", "https://www.zohoapis.com")
).rstrip("/")
@property
def supported_types(self) -> list[CredentialType]:
return [CredentialType.OAUTH2]
def format_for_request(self, token: OAuth2Token) -> dict[str, Any]:
"""
Format token for Zoho CRM API requests.
Zoho uses Authorization header: "Zoho-oauthtoken {access_token}"
(not Bearer).
"""
return {
"headers": {
"Authorization": f"Zoho-oauthtoken {token.access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
}
def validate(self, credential: CredentialObject) -> bool:
"""
Validate Zoho credential by making a lightweight API call.
Uses GET /crm/v2/users?type=CurrentUser (doesn't require module access).
Treats 429 as valid-but-rate-limited.
"""
access_token = credential.get_key("access_token")
if not access_token:
return False
try:
client = self._get_client()
response = client.get(
f"{self._api_domain}/crm/v2/users?type=CurrentUser",
headers={
"Authorization": f"Zoho-oauthtoken {access_token}",
"Accept": "application/json",
},
timeout=self.config.request_timeout,
)
return response.status_code in (200, 429)
except Exception as e:
logger.debug("Zoho credential validation failed: %s", e)
return False
def _parse_token_response(self, response_data: dict[str, Any]) -> OAuth2Token:
"""
Parse Zoho token response.
Zoho returns:
{
"access_token": "...",
"refresh_token": "...",
"expires_in": 3600,
"api_domain": "https://www.zohoapis.com",
"token_type": "Bearer"
}
"""
token = OAuth2Token.from_token_response(response_data)
if "api_domain" in response_data:
token.raw_response["api_domain"] = response_data["api_domain"]
return token
def refresh(self, credential: CredentialObject) -> CredentialObject:
"""Refresh Zoho OAuth2 credential and persist DC metadata."""
refresh_tok = credential.get_key("refresh_token")
if not refresh_tok:
raise CredentialRefreshError(f"Credential '{credential.id}' has no refresh_token")
try:
new_token = self.refresh_access_token(refresh_tok)
except Exception as e:
raise CredentialRefreshError(f"Failed to refresh '{credential.id}': {e}") from e
credential.set_key("access_token", new_token.access_token, expires_at=new_token.expires_at)
if new_token.refresh_token and new_token.refresh_token != refresh_tok:
credential.set_key("refresh_token", new_token.refresh_token)
api_domain = new_token.raw_response.get("api_domain")
if isinstance(api_domain, str) and api_domain:
credential.set_key("api_domain", api_domain.rstrip("/"))
accounts_server = new_token.raw_response.get("accounts-server")
if isinstance(accounts_server, str) and accounts_server:
credential.set_key("accounts_domain", accounts_server.rstrip("/"))
location = new_token.raw_response.get("location")
if isinstance(location, str) and location:
credential.set_key("location", location.strip().lower())
return credential
+1 -1
View File
@@ -568,7 +568,7 @@ def _load_nodes_from_python_agent(agent_path: Path) -> list:
def _load_nodes_from_json_agent(agent_json: Path) -> list:
"""Load nodes from a JSON-based agent."""
try:
with open(agent_json) as f:
with open(agent_json, encoding="utf-8-sig") as f:
data = json.load(f)
from framework.graph import NodeSpec
+4 -4
View File
@@ -203,7 +203,7 @@ class EncryptedFileStorage(CredentialStorage):
# Decrypt
try:
json_bytes = self._fernet.decrypt(encrypted)
data = json.loads(json_bytes.decode())
data = json.loads(json_bytes.decode("utf-8-sig"))
except Exception as e:
raise CredentialDecryptionError(
f"Failed to decrypt credential '{credential_id}': {e}"
@@ -227,7 +227,7 @@ class EncryptedFileStorage(CredentialStorage):
index_path = self.base_path / "metadata" / "index.json"
if not index_path.exists():
return []
with open(index_path) as f:
with open(index_path, encoding="utf-8-sig") as f:
index = json.load(f)
return list(index.get("credentials", {}).keys())
@@ -268,7 +268,7 @@ class EncryptedFileStorage(CredentialStorage):
index_path = self.base_path / "metadata" / "index.json"
if index_path.exists():
with open(index_path) as f:
with open(index_path, encoding="utf-8-sig") as f:
index = json.load(f)
else:
index = {"credentials": {}, "version": "1.0"}
@@ -283,7 +283,7 @@ class EncryptedFileStorage(CredentialStorage):
index["last_modified"] = datetime.now(UTC).isoformat()
with open(index_path, "w") as f:
with open(index_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2)
+1 -2
View File
@@ -431,8 +431,7 @@ class GraphSpec(BaseModel):
max_tokens: int = Field(default=None) # resolved by _resolve_max_tokens validator
# Cleanup LLM for JSON extraction fallback (fast/cheap model preferred)
# If not set, uses CEREBRAS_API_KEY -> cerebras/llama-3.3-70b or
# ANTHROPIC_API_KEY -> claude-haiku-4-5 as fallback
# If not set, uses CEREBRAS_API_KEY -> cerebras/llama-3.3-70b
cleanup_llm_model: str | None = None
# Execution limits
-326
View File
@@ -4058,329 +4058,3 @@ class EventLoopNode(NodeProtocol):
content=json.dumps(result_json, indent=2),
is_error=True,
)
# -------------------------------------------------------------------
# Subagent Execution
# -------------------------------------------------------------------
async def _execute_subagent(
self,
ctx: NodeContext,
agent_id: str,
task: str,
*,
accumulator: OutputAccumulator | 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 — provides outputs that
have been set via ``set_output`` but not yet written to
shared memory (which only happens after the node completes).
Returns:
ToolResult with structured JSON output containing:
- message: Human-readable summary
- data: Subagent's output (free-form JSON)
- metadata: Execution metadata (success, tokens, latency)
"""
from framework.graph.node import NodeContext, SharedMemory
# 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
# Start with everything the parent can read from shared memory.
parent_data = ctx.memory.read_all()
# Merge in-flight outputs from the parent's accumulator.
# set_output() writes to the accumulator but shared memory is only
# updated after the parent node completes — so the subagent would
# otherwise miss any keys the parent set before delegating.
if accumulator:
for key, value in accumulator.to_dict().items():
if key not in parent_data:
parent_data[key] = value
subagent_memory = SharedMemory()
for key, value in parent_data.items():
subagent_memory.write(key, value, validate=False)
# Allow reads for parent data AND the subagent's declared input_keys
# (input_keys may reference keys that exist but weren't in read_all,
# or keys that were just written by the accumulator).
read_keys = set(parent_data.keys()) | set(subagent_spec.input_keys or [])
scoped_memory = subagent_memory.with_permissions(
read_keys=list(read_keys),
write_keys=[], # Read-only!
)
# 2b. 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 self._event_bus:
await self._event_bus.emit_subagent_report(
stream_id=ctx.node_id,
node_id=f"{ctx.node_id}:subagent:{agent_id}",
subagent_id=agent_id,
message=message,
data=data,
execution_id=ctx.execution_id,
)
if not wait_for_response:
return None
if not self._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 = _EscalationReceiver()
registry = ctx.shared_node_registry
registry[escalation_id] = receiver
try:
# Stream message to user (parent's node_id so TUI shows parent talking)
await self._event_bus.emit_client_output_delta(
stream_id=ctx.node_id,
node_id=ctx.node_id,
content=message,
snapshot=message,
execution_id=ctx.execution_id,
)
# Request input (escalation_id for routing response back)
await self._event_bus.emit_client_input_requested(
stream_id=ctx.node_id,
node_id=escalation_id,
prompt=message,
execution_id=ctx.execution_id,
)
# Block until user responds
return await receiver.wait()
finally:
registry.pop(escalation_id, None)
# 3. Filter tools for subagent
# Use the full tool catalog (ctx.all_tools) so subagents can access tools
# that aren't in the parent node's filtered set (e.g. browser tools for a
# GCU subagent when the parent only has web_scrape/save_data).
# Falls back to ctx.available_tools if all_tools is empty (e.g. in tests).
subagent_tool_names = set(subagent_spec.tools or [])
tool_source = ctx.all_tools if ctx.all_tools else ctx.available_tools
subagent_tools = [
t
for t in tool_source
if t.name in subagent_tool_names and t.name != "delegate_to_sub_agent"
]
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(self._config.max_iterations, 10)
subagent_ctx = NodeContext(
runtime=ctx.runtime,
node_id=f"{ctx.node_id}:subagent:{agent_id}",
node_spec=subagent_spec,
memory=scoped_memory,
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
# Derive a conversation store for the subagent from the parent's store.
# Each invocation gets a unique path so that repeated delegate calls
# (e.g. one per profile) don't restore a stale completed conversation.
self._subagent_instance_counter.setdefault(agent_id, 0)
self._subagent_instance_counter[agent_id] += 1
subagent_instance = str(self._subagent_instance_counter[agent_id])
subagent_conv_store = None
if self._conversation_store is not None:
from framework.storage.conversation_store import FileConversationStore
parent_base = getattr(self._conversation_store, "_base", None)
if parent_base is not None:
# Store subagent conversations parallel to the parent node,
# not nested inside it. e.g. conversations/{node}:subagent:{agent_id}:{instance}/
conversations_dir = parent_base.parent # e.g. conversations/
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 so large tool results
# (e.g. browser_snapshot) get written to disk instead of being
# silently truncated. Each instance gets its own directory to
# avoid file collisions between concurrent subagents.
subagent_spillover = None
if self._config.spillover_dir:
subagent_spillover = str(
Path(self._config.spillover_dir) / agent_id / subagent_instance
)
subagent_node = EventLoopNode(
event_bus=None, # Subagents don't emit events to parent's bus
judge=SubagentJudge(task=task, max_iterations=max_iter),
config=LoopConfig(
max_iterations=max_iter, # Tighter budget
max_tool_calls_per_turn=self._config.max_tool_calls_per_turn,
tool_call_overflow_margin=self._config.tool_call_overflow_margin,
max_history_tokens=self._config.max_history_tokens,
stall_detection_threshold=self._config.stall_detection_threshold,
max_tool_result_chars=self._config.max_tool_result_chars,
spillover_dir=subagent_spillover,
),
tool_executor=self._tool_executor,
conversation_store=subagent_conv_store,
)
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)
logger.info(
"\n" + "-" * 60 + "\n"
"✅ SUBAGENT '%s' COMPLETED\n"
"-" * 60 + "\n"
"Success: %s\n"
"Latency: %dms\n"
"Tokens used: %s\n"
"Output keys: %s\n" + "-" * 60,
agent_id,
result.success,
latency_ms,
result.tokens_used,
list(result.output.keys()) if result.output else [],
)
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,
)
+6 -2
View File
@@ -183,11 +183,12 @@ class GraphExecutor:
self.tool_provider_map = tool_provider_map
self.dynamic_tools_provider = dynamic_tools_provider
# Initialize output cleaner
# Initialize output cleaner — uses its own dedicated fast model (CEREBRAS_API_KEY),
# never the main agent LLM. Passing the main LLM here would cause expensive
# Anthropic calls for output cleaning whenever ANTHROPIC_API_KEY is set.
self.cleansing_config = cleansing_config or CleansingConfig()
self.output_cleaner = OutputCleaner(
config=self.cleansing_config,
llm_provider=llm,
)
# Parallel execution settings
@@ -620,11 +621,14 @@ class GraphExecutor:
# node doesn't restore a filled OutputAccumulator from the previous
# webhook run (which would cause the judge to accept immediately).
# The conversation history is preserved (continuous memory).
# Exclude cold restores — those need to continue the conversation
# naturally without a "start fresh" marker.
_is_fresh_shared = bool(
session_state
and session_state.get("resume_session_id")
and not session_state.get("paused_at")
and not session_state.get("resume_from_checkpoint")
and not session_state.get("cold_restore")
)
if _is_fresh_shared and is_continuous and self._storage_path:
try:
+4 -56
View File
@@ -154,69 +154,17 @@ class HITLProtocol:
"""
Parse human's raw input into structured response.
Uses Haiku to intelligently extract answers for each question.
Maps the raw input to the first question. For multi-question HITL,
the caller should present one question at a time.
"""
import os
response = HITLResponse(request_id=request.request_id, raw_input=raw_input)
# If no questions, just return raw input
if not request.questions:
return response
# Try to use Haiku for intelligent parsing
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not use_haiku or not api_key:
# Simple fallback: treat as answer to first question
if request.questions:
response.answers[request.questions[0].id] = raw_input
return response
# Use Haiku to extract answers
try:
import json
import anthropic
questions_str = "\n".join(
[f"{i + 1}. {q.question} (id: {q.id})" for i, q in enumerate(request.questions)]
)
prompt = f"""Parse the user's response and extract answers for each question.
Questions asked:
{questions_str}
User's response:
{raw_input}
Extract the answer for each question. Output JSON with question IDs as keys.
Example format:
{{"question-1": "answer here", "question-2": "answer here"}}"""
client = anthropic.Anthropic(api_key=api_key)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=500,
messages=[{"role": "user", "content": prompt}],
)
# Parse Haiku's response
import re
response_text = message.content[0].text.strip()
json_match = re.search(r"\{[^{}]*\}", response_text, re.DOTALL)
if json_match:
parsed = json.loads(json_match.group())
response.answers = parsed
except Exception:
# Fallback: use raw input for first question
if request.questions:
response.answers[request.questions[0].id] = raw_input
# Map raw input to first question
response.answers[request.questions[0].id] = raw_input
return response
@staticmethod
+7 -54
View File
@@ -585,7 +585,6 @@ class NodeResult:
Generate a human-readable summary of this node's execution and output.
This is like toString() - it describes what the node produced in its current state.
Uses Haiku to intelligently summarize complex outputs.
"""
if not self.success:
return f"❌ Failed: {self.error}"
@@ -593,59 +592,13 @@ class NodeResult:
if not self.output:
return "✓ Completed (no output)"
# Use Haiku to generate intelligent summary
import os
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
# Fallback: simple key-value listing
parts = [f"✓ Completed with {len(self.output)} outputs:"]
for key, value in list(self.output.items())[:5]: # Limit to 5 keys
value_str = str(value)[:100]
if len(str(value)) > 100:
value_str += "..."
parts.append(f"{key}: {value_str}")
return "\n".join(parts)
# Use Haiku to generate intelligent summary
try:
import json
import anthropic
node_context = ""
if node_spec:
node_context = f"\nNode: {node_spec.name}\nPurpose: {node_spec.description}"
output_json = json.dumps(self.output, indent=2, default=str)[:2000]
prompt = (
f"Generate a 1-2 sentence human-readable summary of "
f"what this node produced.{node_context}\n\n"
f"Node output:\n{output_json}\n\n"
"Provide a concise, clear summary that a human can quickly "
"understand. Focus on the key information produced."
)
client = anthropic.Anthropic(api_key=api_key)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=200,
messages=[{"role": "user", "content": prompt}],
)
summary = message.content[0].text.strip()
return f"{summary}"
except Exception:
# Fallback on error
parts = [f"✓ Completed with {len(self.output)} outputs:"]
for key, value in list(self.output.items())[:3]:
value_str = str(value)[:80]
if len(str(value)) > 80:
value_str += "..."
parts.append(f"{key}: {value_str}")
return "\n".join(parts)
parts = [f"✓ Completed with {len(self.output)} outputs:"]
for key, value in list(self.output.items())[:5]: # Limit to 5 keys
value_str = str(value)[:100]
if len(str(value)) > 100:
value_str += "..."
parts.append(f"{key}: {value_str}")
return "\n".join(parts)
class NodeProtocol(ABC):
+1 -1
View File
@@ -170,7 +170,7 @@ def _dump_failed_request(
"temperature": kwargs.get("temperature"),
}
with open(filepath, "w") as f:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(dump_data, f, indent=2, default=str)
return str(filepath)
+9 -81
View File
@@ -162,7 +162,7 @@ def _load_session(session_id: str) -> BuildSession:
if not session_file.exists():
raise ValueError(f"Session '{session_id}' not found")
with open(session_file) as f:
with open(session_file, encoding="utf-8") as f:
data = json.load(f)
return BuildSession.from_dict(data)
@@ -174,7 +174,7 @@ def _load_active_session() -> BuildSession | None:
return None
try:
with open(ACTIVE_SESSION_FILE) as f:
with open(ACTIVE_SESSION_FILE, encoding="utf-8") as f:
session_id = f.read().strip()
if session_id:
@@ -228,7 +228,7 @@ def list_sessions() -> str:
if SESSIONS_DIR.exists():
for session_file in SESSIONS_DIR.glob("*.json"):
try:
with open(session_file) as f:
with open(session_file, encoding="utf-8") as f:
data = json.load(f)
sessions.append(
{
@@ -248,7 +248,7 @@ def list_sessions() -> str:
active_id = None
if ACTIVE_SESSION_FILE.exists():
try:
with open(ACTIVE_SESSION_FILE) as f:
with open(ACTIVE_SESSION_FILE, encoding="utf-8") as f:
active_id = f.read().strip()
except Exception:
pass
@@ -310,7 +310,7 @@ def delete_session(session_id: Annotated[str, "ID of the session to delete"]) ->
_session = None
if ACTIVE_SESSION_FILE.exists():
with open(ACTIVE_SESSION_FILE) as f:
with open(ACTIVE_SESSION_FILE, encoding="utf-8") as f:
active_id = f.read().strip()
if active_id == session_id:
ACTIVE_SESSION_FILE.unlink()
@@ -2894,10 +2894,12 @@ def run_tests(
try:
result = subprocess.run(
cmd,
encoding="utf-8",
capture_output=True,
text=True,
timeout=600, # 10 minute timeout
env=env,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return json.dumps(
@@ -3085,10 +3087,12 @@ def debug_test(
try:
result = subprocess.run(
cmd,
encoding="utf-8",
capture_output=True,
text=True,
timeout=120, # 2 minute timeout for single test
env=env,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return json.dumps(
@@ -3712,82 +3716,6 @@ def list_agent_sessions(
)
@mcp.tool()
def get_agent_session_state(
agent_work_dir: Annotated[str, "Path to the agent's working directory"],
session_id: Annotated[str, "The session ID (e.g., 'session_20260208_143022_abc12345')"],
) -> str:
"""
Load full session state for a specific session.
Returns complete session data including status, progress, result,
metrics, and checkpoint info. Memory values are excluded to prevent
context bloat -- use get_agent_session_memory to retrieve memory contents.
"""
state_path = Path(agent_work_dir) / "sessions" / session_id / "state.json"
data = _read_session_json(state_path)
if data is None:
return json.dumps({"error": f"Session not found: {session_id}"})
memory = data.get("memory", {})
data["memory_keys"] = list(memory.keys()) if isinstance(memory, dict) else []
data["memory_size"] = len(memory) if isinstance(memory, dict) else 0
data.pop("memory", None)
return json.dumps(data, indent=2, default=str)
@mcp.tool()
def get_agent_session_memory(
agent_work_dir: Annotated[str, "Path to the agent's working directory"],
session_id: Annotated[str, "The session ID"],
key: Annotated[str, "Specific memory key to retrieve. Empty for all."] = "",
) -> str:
"""
Get memory contents from a session.
Memory stores intermediate results passed between nodes. Use this
to inspect what data was produced during execution.
If key is provided, returns only that memory key's value.
If key is empty, returns all memory keys and their values.
"""
state_path = Path(agent_work_dir) / "sessions" / session_id / "state.json"
data = _read_session_json(state_path)
if data is None:
return json.dumps({"error": f"Session not found: {session_id}"})
memory = data.get("memory", {})
if not isinstance(memory, dict):
memory = {}
if key:
if key not in memory:
return json.dumps(
{
"error": f"Memory key not found: '{key}'",
"available_keys": list(memory.keys()),
}
)
value = memory[key]
return json.dumps(
{
"session_id": session_id,
"key": key,
"value": value,
"value_type": type(value).__name__,
},
indent=2,
default=str,
)
return json.dumps(
{"session_id": session_id, "memory": memory, "total_keys": len(memory)},
indent=2,
default=str,
)
@mcp.tool()
def list_agent_checkpoints(
agent_work_dir: Annotated[str, "Path to the agent's working directory"],
+89 -57
View File
@@ -401,6 +401,43 @@ def register_commands(subparsers: argparse._SubParsersAction) -> None:
)
serve_parser.set_defaults(func=cmd_serve)
# open command (serve + auto-open browser)
open_parser = subparsers.add_parser(
"open",
help="Start HTTP server and open dashboard in browser",
description="Shortcut for 'hive serve --open'. "
"Starts the HTTP server and opens the dashboard.",
)
open_parser.add_argument(
"--host",
type=str,
default="127.0.0.1",
help="Host to bind (default: 127.0.0.1)",
)
open_parser.add_argument(
"--port",
"-p",
type=int,
default=8787,
help="Port to listen on (default: 8787)",
)
open_parser.add_argument(
"--agent",
"-a",
type=str,
action="append",
default=[],
help="Agent path to preload (repeatable)",
)
open_parser.add_argument(
"--model",
"-m",
type=str,
default=None,
help="LLM model for preloaded agents",
)
open_parser.set_defaults(func=cmd_open)
def _load_resume_state(
agent_path: str, session_id: str, checkpoint_id: str | None = None
@@ -517,11 +554,28 @@ def cmd_run(args: argparse.Namespace) -> int:
return 1
elif args.input_file:
try:
with open(args.input_file) as f:
with open(args.input_file, encoding="utf-8") as f:
context = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error reading input file: {e}", file=sys.stderr)
return 1
# Validate --output path before execution begins (fail fast, before agent loads)
if args.output:
import os
output_parent = Path(args.output).parent
if not output_parent.exists():
print(
f"Error: output directory does not exist: {output_parent}/",
file=sys.stderr,
)
return 1
if not os.access(output_parent, os.W_OK):
print(
f"Error: output directory is not writable: {output_parent}/",
file=sys.stderr,
)
return 1
# Run the agent (with TUI or standard)
if getattr(args, "tui", False):
@@ -659,7 +713,7 @@ def cmd_run(args: argparse.Namespace) -> int:
# Output results
if args.output:
with open(args.output, "w") as f:
with open(args.output, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, default=str)
if not args.quiet:
print(f"Results written to {args.output}")
@@ -1053,62 +1107,19 @@ def _interactive_approval(request):
def _format_natural_language_to_json(
user_input: str, input_keys: list[str], agent_description: str, session_context: dict = None
) -> dict:
"""Use Haiku to convert natural language input to JSON based on agent's input schema."""
import os
"""Convert natural language input to JSON based on agent's input schema.
import anthropic
Maps user input to the primary input field. For follow-up inputs,
appends to the existing value.
"""
main_field = input_keys[0] if input_keys else "objective"
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
# Build prompt for Haiku
session_info = ""
if session_context:
# Extract the main field (usually 'objective') that we'll append to
main_field = input_keys[0] if input_keys else "objective"
existing_value = session_context.get(main_field, "")
if existing_value:
return {main_field: f"{existing_value}\n\n{user_input}"}
session_info = (
f'\n\nExisting {main_field}: "{existing_value}"\n\n'
f"The user is providing ADDITIONAL information. Append this new "
f"information to the existing {main_field} to create an enriched, "
"more detailed version."
)
prompt = f"""You are formatting user input for an agent that requires specific input fields.
Agent: {agent_description}
Required input fields: {", ".join(input_keys)}{session_info}
User input: {user_input}
{"If this is a follow-up, APPEND new info to the existing field value." if session_context else ""}
Output ONLY valid JSON, no explanation:"""
try:
message = client.messages.create(
model="claude-haiku-4-5-20251001", # Fast and cheap
max_tokens=500,
messages=[{"role": "user", "content": prompt}],
)
json_str = message.content[0].text.strip()
# Remove markdown code blocks if present
if json_str.startswith("```"):
json_str = json_str.split("```")[1]
if json_str.startswith("json"):
json_str = json_str[4:]
json_str = json_str.strip()
return json.loads(json_str)
except Exception:
# Fallback: try to infer the main field
if len(input_keys) == 1:
return {input_keys[0]: user_input}
else:
# Put it in the first field as fallback
return {input_keys[0]: user_input}
return {main_field: user_input}
def cmd_shell(args: argparse.Namespace) -> int:
@@ -1517,7 +1528,7 @@ def _extract_python_agent_metadata(agent_path: Path) -> tuple[str, str]:
return fallback_name, fallback_desc
try:
with open(config_path) as f:
with open(config_path, encoding="utf-8") as f:
tree = ast.parse(f.read())
# Find AgentMetadata class definition
@@ -1928,14 +1939,27 @@ def cmd_setup_credentials(args: argparse.Namespace) -> int:
def _open_browser(url: str) -> None:
"""Open URL in the default browser (best-effort, non-blocking)."""
import subprocess
import sys
try:
if sys.platform == "darwin":
subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(
["open", url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
encoding="utf-8",
)
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
["xdg-open", url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
encoding="utf-8",
)
except Exception:
pass # Best-effort — don't crash if browser can't open
@@ -1980,12 +2004,14 @@ def _build_frontend() -> bool:
# Ensure deps are installed
subprocess.run(
["npm", "install", "--no-fund", "--no-audit"],
encoding="utf-8",
cwd=frontend_dir,
check=True,
capture_output=True,
)
subprocess.run(
["npm", "run", "build"],
encoding="utf-8",
cwd=frontend_dir,
check=True,
capture_output=True,
@@ -2074,3 +2100,9 @@ def cmd_serve(args: argparse.Namespace) -> int:
print("\nServer stopped.")
return 0
def cmd_open(args: argparse.Namespace) -> int:
"""Start the HTTP API server and open the dashboard in the browser."""
args.open = True
return cmd_serve(args)
+27 -8
View File
@@ -7,6 +7,8 @@ Supports both STDIO and HTTP transports using the official MCP Python SDK.
import asyncio
import logging
import os
import sys
import threading
from dataclasses import dataclass, field
from typing import Any, Literal
@@ -73,6 +75,8 @@ class MCPClient:
# Background event loop for persistent STDIO connection
self._loop = None
self._loop_thread = None
# Serialize STDIO tool calls (avoids races, helps on Windows)
self._stdio_call_lock = threading.Lock()
def _run_async(self, coro):
"""
@@ -156,11 +160,19 @@ class MCPClient:
# Create server parameters
# Always inherit parent environment and merge with any custom env vars
merged_env = {**os.environ, **(self.config.env or {})}
# On Windows, passing cwd can cause WinError 267 ("invalid directory name").
# tool_registry passes cwd=None and uses absolute script paths when applicable.
cwd = self.config.cwd
if os.name == "nt" and cwd is not None:
# Avoid passing cwd on Windows; tool_registry should have set cwd=None
# and absolute script paths for tools-dir servers. If cwd is still set,
# pass None to prevent WinError 267 (caller should use absolute paths).
cwd = None
server_params = StdioServerParameters(
command=self.config.command,
args=self.config.args,
env=merged_env,
cwd=self.config.cwd,
cwd=cwd,
)
# Store for later use
@@ -184,10 +196,12 @@ class MCPClient:
from mcp.client.stdio import stdio_client
# Create persistent stdio client context.
# Redirect server stderr to devnull to prevent raw
# output from leaking behind the TUI.
devnull = open(os.devnull, "w") # noqa: SIM115
self._stdio_context = stdio_client(server_params, errlog=devnull)
# On Windows, use stderr so subprocess startup errors are visible.
if os.name == "nt":
errlog = sys.stderr
else:
errlog = open(os.devnull, "w") # noqa: SIM115
self._stdio_context = stdio_client(server_params, errlog=errlog)
(
self._read_stream,
self._write_stream,
@@ -353,7 +367,8 @@ class MCPClient:
raise ValueError(f"Unknown tool: {tool_name}")
if self.config.transport == "stdio":
return self._run_async(self._call_tool_stdio_async(tool_name, arguments))
with self._stdio_call_lock:
return self._run_async(self._call_tool_stdio_async(tool_name, arguments))
else:
return self._call_tool_http(tool_name, arguments)
@@ -448,11 +463,15 @@ class MCPClient:
if self._stdio_context:
await self._stdio_context.__aexit__(None, None, None)
except asyncio.CancelledError:
logger.warning(
logger.debug(
"STDIO context cleanup was cancelled; proceeding with best-effort shutdown"
)
except Exception as e:
logger.warning(f"Error closing STDIO context: {e}")
msg = str(e).lower()
if "cancel scope" in msg or "different task" in msg:
logger.debug("STDIO context teardown (known anyio quirk): %s", e)
else:
logger.warning(f"Error closing STDIO context: {e}")
finally:
self._stdio_context = None
+137 -33
View File
@@ -39,6 +39,7 @@ logger = logging.getLogger(__name__)
CLAUDE_CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json"
CLAUDE_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"
# Buffer in seconds before token expiry to trigger a proactive refresh
_TOKEN_REFRESH_BUFFER_SECS = 300 # 5 minutes
@@ -51,6 +52,96 @@ CODEX_KEYCHAIN_SERVICE = "Codex Auth"
_CODEX_TOKEN_LIFETIME_SECS = 3600 # 1 hour (no explicit expiry field)
def _read_claude_keychain() -> dict | None:
"""Read Claude Code credentials from macOS Keychain.
Returns the parsed JSON dict, or None if not on macOS or entry missing.
"""
import getpass
import platform
import subprocess
if platform.system() != "Darwin":
return None
try:
account = getpass.getuser()
result = subprocess.run(
[
"security",
"find-generic-password",
"-s",
CLAUDE_KEYCHAIN_SERVICE,
"-a",
account,
"-w",
],
capture_output=True,
encoding="utf-8",
timeout=5,
)
if result.returncode != 0:
return None
raw = result.stdout.strip()
if not raw:
return None
return json.loads(raw)
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
logger.debug("Claude keychain read failed: %s", exc)
return None
def _save_claude_keychain(creds: dict) -> bool:
"""Write Claude Code credentials to macOS Keychain. Returns True on success."""
import getpass
import platform
import subprocess
if platform.system() != "Darwin":
return False
try:
account = getpass.getuser()
data = json.dumps(creds)
result = subprocess.run(
[
"security",
"add-generic-password",
"-U",
"-s",
CLAUDE_KEYCHAIN_SERVICE,
"-a",
account,
"-w",
data,
],
capture_output=True,
timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("Claude keychain write failed: %s", exc)
return False
def _read_claude_credentials() -> dict | None:
"""Read Claude Code credentials from Keychain (macOS) or file (Linux/Windows)."""
# Try macOS Keychain first
creds = _read_claude_keychain()
if creds:
return creds
# Fall back to file
if not CLAUDE_CREDENTIALS_FILE.exists():
return None
try:
with open(CLAUDE_CREDENTIALS_FILE, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def _refresh_claude_code_token(refresh_token: str) -> dict | None:
"""Refresh the Claude Code OAuth token using the refresh token.
@@ -89,16 +180,14 @@ def _refresh_claude_code_token(refresh_token: str) -> dict | None:
def _save_refreshed_credentials(token_data: dict) -> None:
"""Write refreshed token data back to ~/.claude/.credentials.json."""
"""Write refreshed token data back to Keychain (macOS) or credentials file."""
import time
if not CLAUDE_CREDENTIALS_FILE.exists():
creds = _read_claude_credentials()
if not creds:
return
try:
with open(CLAUDE_CREDENTIALS_FILE) as f:
creds = json.load(f)
oauth = creds.get("claudeAiOauth", {})
oauth["accessToken"] = token_data["access_token"]
if "refresh_token" in token_data:
@@ -107,9 +196,15 @@ def _save_refreshed_credentials(token_data: dict) -> None:
oauth["expiresAt"] = int((time.time() + token_data["expires_in"]) * 1000)
creds["claudeAiOauth"] = oauth
with open(CLAUDE_CREDENTIALS_FILE, "w") as f:
json.dump(creds, f, indent=2)
logger.debug("Claude Code credentials refreshed successfully")
# Try Keychain first (macOS), fall back to file
if _save_claude_keychain(creds):
logger.debug("Claude Code credentials refreshed in Keychain")
return
if CLAUDE_CREDENTIALS_FILE.exists():
with open(CLAUDE_CREDENTIALS_FILE, "w", encoding="utf-8") as f:
json.dump(creds, f, indent=2)
logger.debug("Claude Code credentials refreshed in file")
except (json.JSONDecodeError, OSError, KeyError) as exc:
logger.debug("Failed to save refreshed credentials: %s", exc)
@@ -117,8 +212,8 @@ def _save_refreshed_credentials(token_data: dict) -> None:
def get_claude_code_token() -> str | None:
"""Get the OAuth token from Claude Code subscription with auto-refresh.
Reads from ~/.claude/.credentials.json which is created by the
Claude Code CLI when users authenticate with their subscription.
Reads from macOS Keychain (on Darwin) or ~/.claude/.credentials.json
(on Linux/Windows), as created by the Claude Code CLI.
If the token is expired or close to expiry, attempts an automatic
refresh using the stored refresh token.
@@ -128,13 +223,8 @@ def get_claude_code_token() -> str | None:
"""
import time
if not CLAUDE_CREDENTIALS_FILE.exists():
return None
try:
with open(CLAUDE_CREDENTIALS_FILE) as f:
creds = json.load(f)
except (json.JSONDecodeError, OSError):
creds = _read_claude_credentials()
if not creds:
return None
oauth = creds.get("claudeAiOauth", {})
@@ -212,7 +302,7 @@ def _read_codex_keychain() -> dict | None:
"-w",
],
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
)
if result.returncode != 0:
@@ -231,7 +321,7 @@ def _read_codex_auth_file() -> dict | None:
if not CODEX_AUTH_FILE.exists():
return None
try:
with open(CODEX_AUTH_FILE) as f:
with open(CODEX_AUTH_FILE, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
@@ -324,7 +414,7 @@ def _save_refreshed_codex_credentials(auth_data: dict, token_data: dict) -> None
CODEX_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd = os.open(CODEX_AUTH_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(auth_data, f, indent=2)
logger.debug("Codex credentials refreshed successfully")
except (OSError, KeyError) as exc:
@@ -621,6 +711,7 @@ class AgentRunner:
requires_account_selection: bool = False,
configure_for_account: Callable | None = None,
list_accounts: Callable | None = None,
credential_store: Any | None = None,
):
"""
Initialize the runner (use AgentRunner.load() instead).
@@ -640,6 +731,7 @@ class AgentRunner:
requires_account_selection: If True, TUI shows account picker before starting.
configure_for_account: Callback(runner, account_dict) to scope tools after selection.
list_accounts: Callback() -> list[dict] to fetch available accounts.
credential_store: Optional shared CredentialStore (avoids creating redundant stores).
"""
self.agent_path = agent_path
self.graph = graph
@@ -653,6 +745,7 @@ class AgentRunner:
self.requires_account_selection = requires_account_selection
self._configure_for_account = configure_for_account
self._list_accounts = list_accounts
self._credential_store = credential_store
# Set up storage
if storage_path:
@@ -750,6 +843,7 @@ class AgentRunner:
model: str | None = None,
interactive: bool = True,
skip_credential_validation: bool | None = None,
credential_store: Any | None = None,
) -> "AgentRunner":
"""
Load an agent from an export folder.
@@ -767,6 +861,7 @@ class AgentRunner:
Set to False from TUI callers that handle setup via their own UI.
skip_credential_validation: If True, skip credential checks at load time.
When None (default), uses the agent module's setting.
credential_store: Optional shared CredentialStore (avoids creating redundant stores).
Returns:
AgentRunner instance ready to run
@@ -856,15 +951,19 @@ class AgentRunner:
requires_account_selection=needs_acct,
configure_for_account=configure_fn,
list_accounts=list_accts_fn,
credential_store=credential_store,
)
# Fallback: load from agent.json (legacy JSON-based agents)
agent_json_path = agent_path / "agent.json"
if not agent_json_path.exists():
if not agent_json_path.is_file():
raise FileNotFoundError(f"No agent.py or agent.json found in {agent_path}")
with open(agent_json_path) as f:
graph, goal = load_agent_export(f.read())
content = agent_json_path.read_text(encoding="utf-8").strip()
if not content:
raise FileNotFoundError(f"agent.json is empty: {agent_json_path}")
graph, goal = load_agent_export(content)
return cls(
agent_path=agent_path,
@@ -875,6 +974,7 @@ class AgentRunner:
model=model,
interactive=interactive,
skip_credential_validation=skip_credential_validation or False,
credential_store=credential_store,
)
def register_tool(
@@ -1108,11 +1208,10 @@ class AgentRunner:
# Auto-register GCU MCP server if tools aren't loaded yet
gcu_tool_names = self._tool_registry.get_server_tool_names(GCU_SERVER_NAME)
if not gcu_tool_names:
# Resolve relative cwd against agent path
# Resolve cwd to repo-level tools/ (not relative to agent_path)
gcu_config = dict(GCU_MCP_SERVER_CONFIG)
cwd = gcu_config.get("cwd")
if cwd and not Path(cwd).is_absolute():
gcu_config["cwd"] = str((self.agent_path / cwd).resolve())
_repo_root = Path(__file__).resolve().parent.parent.parent.parent
gcu_config["cwd"] = str(_repo_root / "tools")
self._tool_registry.register_mcp_server(gcu_config)
gcu_tool_names = self._tool_registry.get_server_tool_names(GCU_SERVER_NAME)
@@ -1132,10 +1231,10 @@ class AgentRunner:
files_tool_names = self._tool_registry.get_server_tool_names(FILES_MCP_SERVER_NAME)
if not files_tool_names:
# Resolve cwd to repo-level tools/ (not relative to agent_path)
files_config = dict(FILES_MCP_SERVER_CONFIG)
cwd = files_config.get("cwd")
if cwd and not Path(cwd).is_absolute():
files_config["cwd"] = str((self.agent_path / cwd).resolve())
_repo_root = Path(__file__).resolve().parent.parent.parent.parent
files_config["cwd"] = str(_repo_root / "tools")
self._tool_registry.register_mcp_server(files_config)
files_tool_names = self._tool_registry.get_server_tool_names(FILES_MCP_SERVER_NAME)
@@ -1158,7 +1257,10 @@ class AgentRunner:
try:
from aden_tools.credentials.store_adapter import CredentialStoreAdapter
adapter = CredentialStoreAdapter.default()
if self._credential_store is not None:
adapter = CredentialStoreAdapter(store=self._credential_store)
else:
adapter = CredentialStoreAdapter.default()
accounts_data = adapter.get_all_account_info()
tool_provider_map = adapter.get_tool_provider_map()
if accounts_data:
@@ -1229,9 +1331,11 @@ class AgentRunner:
return None
try:
from framework.credentials import CredentialStore
store = self._credential_store
if store is None:
from framework.credentials import CredentialStore
store = CredentialStore.with_encrypted_storage()
store = CredentialStore.with_encrypted_storage()
return store.get(cred_id)
except Exception:
return None
+104 -4
View File
@@ -326,6 +326,103 @@ class ToolRegistry:
"""Restore execution context to its previous state."""
_execution_context.reset(token)
@staticmethod
def resolve_mcp_stdio_config(server_config: dict[str, Any], base_dir: Path) -> dict[str, Any]:
"""Resolve cwd and script paths for MCP stdio config (Windows compatibility).
Use this when building MCPServerConfig from a config file (e.g. in
list_agent_tools, discover_mcp_tools) so hive-tools and other servers
work on Windows. Call with base_dir = directory containing the config.
"""
registry = ToolRegistry()
return registry._resolve_mcp_server_config(server_config, base_dir)
def _resolve_mcp_server_config(
self, server_config: dict[str, Any], base_dir: Path
) -> dict[str, Any]:
"""Resolve cwd and script paths for MCP stdio servers (Windows compatibility).
On Windows, passing cwd to subprocess can cause WinError 267. We use cwd=None
and absolute script paths when the server runs a .py script from the tools dir.
If the resolved cwd doesn't exist (e.g. config from ~/.hive/agents/), fall back
to Path.cwd() / "tools".
"""
config = dict(server_config)
if config.get("transport") != "stdio":
return config
cwd = config.get("cwd")
args = list(config.get("args", []))
if not cwd and not args:
return config
# Resolve cwd relative to base_dir
resolved_cwd: Path | None = None
if cwd:
if Path(cwd).is_absolute():
resolved_cwd = Path(cwd)
else:
resolved_cwd = (base_dir / cwd).resolve()
# Find .py script in args (e.g. coder_tools_server.py, files_server.py)
script_name = None
for i, arg in enumerate(args):
if isinstance(arg, str) and arg.endswith(".py"):
script_name = arg
script_idx = i
break
if resolved_cwd is None:
return config
# If resolved cwd doesn't exist or (when we have a script) doesn't contain it,
# try fallback
tools_fallback = Path.cwd() / "tools"
need_fallback = not resolved_cwd.is_dir()
if script_name and not need_fallback:
need_fallback = not (resolved_cwd / script_name).exists()
if need_fallback:
fallback_ok = tools_fallback.is_dir()
if script_name:
fallback_ok = fallback_ok and (tools_fallback / script_name).exists()
else:
# No script (e.g. GCU); just need tools dir to exist
pass
if fallback_ok:
resolved_cwd = tools_fallback
logger.debug(
"MCP server '%s': using fallback tools dir %s",
config.get("name", "?"),
resolved_cwd,
)
else:
config["cwd"] = str(resolved_cwd)
return config
if not script_name:
# No .py script (e.g. GCU uses -m gcu.server); just set cwd
config["cwd"] = str(resolved_cwd)
return config
# For coder_tools_server, inject --project-root so writes go to the expected workspace
if script_name and "coder_tools" in script_name:
project_root = str(resolved_cwd.parent.resolve())
args = list(args)
if "--project-root" not in args:
args.extend(["--project-root", project_root])
config["args"] = args
if os.name == "nt":
# Windows: cwd=None avoids WinError 267; use absolute script path
config["cwd"] = None
abs_script = str((resolved_cwd / script_name).resolve())
args = list(config["args"])
args[script_idx] = abs_script
config["args"] = args
else:
config["cwd"] = str(resolved_cwd)
return config
def load_mcp_config(self, config_path: Path) -> None:
"""
Load and register MCP servers from a config file.
@@ -340,7 +437,7 @@ class ToolRegistry:
self._mcp_config_path = Path(config_path)
try:
with open(config_path) as f:
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
except Exception as e:
logger.warning(f"Failed to load MCP config from {config_path}: {e}")
@@ -357,9 +454,7 @@ class ToolRegistry:
server_list = [{"name": name, **cfg} for name, cfg in config.items()]
for server_config in server_list:
cwd = server_config.get("cwd")
if cwd and not Path(cwd).is_absolute():
server_config["cwd"] = str((base_dir / cwd).resolve())
server_config = self._resolve_mcp_server_config(server_config, base_dir)
try:
self.register_mcp_server(server_config)
except Exception as e:
@@ -480,6 +575,11 @@ class ToolRegistry:
except Exception as e:
logger.error(f"Failed to register MCP server: {e}")
if "Connection closed" in str(e) and os.name == "nt":
logger.debug(
"On Windows, check that the MCP subprocess starts (e.g. uv in PATH, "
"script path correct). Worker config uses base_dir = mcp_servers.json parent."
)
return 0
def _convert_mcp_tool_to_framework_tool(self, mcp_tool: Any) -> Tool:
+36
View File
@@ -1270,6 +1270,42 @@ class AgentRuntime:
"""Get the registration for a specific graph (or None)."""
return self._graphs.get(graph_id)
def cancel_all_tasks(self, loop: asyncio.AbstractEventLoop) -> bool:
"""Cancel all running execution tasks across all graphs.
Schedules the cancellation on *loop* (the agent event loop) so
that ``_execution_tasks`` is only read from the thread that owns
it, avoiding cross-thread dict access. Safe to call from any
thread (e.g. the Textual UI thread).
Blocks the caller for up to 5 seconds waiting for the result.
For async callers, use :meth:`cancel_all_tasks_async` instead.
"""
future = asyncio.run_coroutine_threadsafe(self.cancel_all_tasks_async(), loop)
try:
return future.result(timeout=5)
except Exception:
logger.warning("cancel_all_tasks: timed out or failed")
return False
async def cancel_all_tasks_async(self) -> bool:
"""Cancel all running execution tasks (runs on the agent loop).
Iterates ``_execution_tasks`` and calls ``task.cancel()`` directly.
Must be awaited on the agent event loop so dict access is
thread-safe. Returns True if at least one task was cancelled.
"""
cancelled = False
for gid in self.list_graphs():
reg = self.get_graph_registration(gid)
if reg:
for stream in reg.streams.values():
for task in list(stream._execution_tasks.values()):
if task and not task.done():
task.cancel()
cancelled = True
return cancelled
def _get_primary_session_state(
self,
exclude_entry_point: str,
@@ -821,5 +821,148 @@ class TestTimerEntryPoints:
await runtime.stop()
# === Cancel All Tasks Tests ===
class TestCancelAllTasks:
"""Tests for cancel_all_tasks and cancel_all_tasks_async."""
@pytest.mark.asyncio
async def test_cancel_all_tasks_async_returns_false_when_no_tasks(
self, sample_graph, sample_goal, temp_storage
):
"""Test that cancel_all_tasks_async returns False with no running tasks."""
runtime = AgentRuntime(
graph=sample_graph,
goal=sample_goal,
storage_path=temp_storage,
)
entry_spec = EntryPointSpec(
id="webhook",
name="Webhook",
entry_node="process-webhook",
trigger_type="webhook",
)
runtime.register_entry_point(entry_spec)
await runtime.start()
try:
result = await runtime.cancel_all_tasks_async()
assert result is False
finally:
await runtime.stop()
@pytest.mark.asyncio
async def test_cancel_all_tasks_async_cancels_running_task(
self, sample_graph, sample_goal, temp_storage
):
"""Test that cancel_all_tasks_async cancels a running task and returns True."""
runtime = AgentRuntime(
graph=sample_graph,
goal=sample_goal,
storage_path=temp_storage,
)
entry_spec = EntryPointSpec(
id="webhook",
name="Webhook",
entry_node="process-webhook",
trigger_type="webhook",
)
runtime.register_entry_point(entry_spec)
await runtime.start()
try:
# Inject a fake running task into the stream
stream = runtime._streams["webhook"]
async def hang_forever():
await asyncio.get_event_loop().create_future()
fake_task = asyncio.ensure_future(hang_forever())
stream._execution_tasks["fake-exec"] = fake_task
result = await runtime.cancel_all_tasks_async()
assert result is True
# Let the CancelledError propagate
try:
await fake_task
except asyncio.CancelledError:
pass
assert fake_task.cancelled()
# Clean up
del stream._execution_tasks["fake-exec"]
finally:
await runtime.stop()
@pytest.mark.asyncio
async def test_cancel_all_tasks_async_cancels_multiple_tasks_across_streams(
self, sample_graph, sample_goal, temp_storage
):
"""Test that cancel_all_tasks_async cancels tasks across multiple streams."""
runtime = AgentRuntime(
graph=sample_graph,
goal=sample_goal,
storage_path=temp_storage,
)
# Register two entry points so we get two streams
runtime.register_entry_point(
EntryPointSpec(
id="stream-a",
name="Stream A",
entry_node="process-webhook",
trigger_type="webhook",
)
)
runtime.register_entry_point(
EntryPointSpec(
id="stream-b",
name="Stream B",
entry_node="process-webhook",
trigger_type="webhook",
)
)
await runtime.start()
try:
async def hang_forever():
await asyncio.get_event_loop().create_future()
stream_a = runtime._streams["stream-a"]
stream_b = runtime._streams["stream-b"]
# Two tasks in stream A, one task in stream B
task_a1 = asyncio.ensure_future(hang_forever())
task_a2 = asyncio.ensure_future(hang_forever())
task_b1 = asyncio.ensure_future(hang_forever())
stream_a._execution_tasks["exec-a1"] = task_a1
stream_a._execution_tasks["exec-a2"] = task_a2
stream_b._execution_tasks["exec-b1"] = task_b1
result = await runtime.cancel_all_tasks_async()
assert result is True
# Let CancelledErrors propagate
for task in [task_a1, task_a2, task_b1]:
try:
await task
except asyncio.CancelledError:
pass
assert task.cancelled()
# Clean up
del stream_a._execution_tasks["exec-a1"]
del stream_a._execution_tasks["exec-a2"]
del stream_b._execution_tasks["exec-b1"]
finally:
await runtime.stop()
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+6 -6
View File
@@ -176,10 +176,7 @@ def create_app(model: str | None = None) -> web.Application:
"""
app = web.Application(middlewares=[cors_middleware, error_middleware])
# Store manager on app for handlers
app["manager"] = SessionManager(model=model)
# Initialize credential store
# Initialize credential store (before SessionManager so it can be shared)
from framework.credentials.store import CredentialStore
try:
@@ -200,10 +197,13 @@ def create_app(model: str | None = None) -> web.Application:
except Exception as exc:
logger.warning("Could not auto-persist HIVE_CREDENTIAL_KEY: %s", exc)
app["credential_store"] = CredentialStore.with_aden_sync()
credential_store = CredentialStore.with_aden_sync()
except Exception:
logger.debug("Encrypted credential store unavailable, using in-memory fallback")
app["credential_store"] = CredentialStore.for_testing({})
credential_store = CredentialStore.for_testing({})
app["credential_store"] = credential_store
app["manager"] = SessionManager(model=model, credential_store=credential_store)
# Register shutdown hook
app.on_shutdown.append(_on_shutdown)
+10 -2
View File
@@ -4,6 +4,7 @@ import asyncio
import logging
from aiohttp import web
from aiohttp.client_exceptions import ClientConnectionResetError as _AiohttpConnReset
from framework.runtime.event_bus import EventType
from framework.server.app import resolve_session
@@ -168,8 +169,15 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
"SSE first event: session='%s', type='%s'", session.id, data.get("type")
)
except TimeoutError:
await sse.send_keepalive()
except (ConnectionResetError, ConnectionError):
try:
await sse.send_keepalive()
except (ConnectionResetError, ConnectionError, _AiohttpConnReset):
close_reason = "client_disconnected"
break
except Exception as exc:
close_reason = f"keepalive_error: {exc}"
break
except (ConnectionResetError, ConnectionError, _AiohttpConnReset):
close_reason = "client_disconnected"
break
except Exception as exc:
+55 -1
View File
@@ -288,6 +288,60 @@ async def handle_resume(request: web.Request) -> web.Response:
)
async def handle_pause(request: web.Request) -> web.Response:
"""POST /api/sessions/{session_id}/pause — pause the worker (queen stays alive).
Mirrors the queen's stop_worker() tool: cancels all active worker
executions, pauses timers so nothing auto-restarts, but does NOT
touch the queen so she can observe and react to the pause.
"""
session, err = resolve_session(request)
if err:
return err
if not session.worker_runtime:
return web.json_response({"error": "No worker loaded in this session"}, status=503)
runtime = session.worker_runtime
cancelled = []
for graph_id in runtime.list_graphs():
reg = runtime.get_graph_registration(graph_id)
if reg is None:
continue
for _ep_id, stream in reg.streams.items():
# Signal shutdown on active nodes to abort in-flight LLM streams
for executor in stream._active_executors.values():
for node in executor.node_registry.values():
if hasattr(node, "signal_shutdown"):
node.signal_shutdown()
if hasattr(node, "cancel_current_turn"):
node.cancel_current_turn()
for exec_id in list(stream.active_execution_ids):
try:
ok = await stream.cancel_execution(exec_id)
if ok:
cancelled.append(exec_id)
except Exception:
pass
# Pause timers so the next tick doesn't restart execution
runtime.pause_timers()
# Switch to staging (agent still loaded, ready to re-run)
if session.mode_state is not None:
await session.mode_state.switch_to_staging(source="frontend")
return web.json_response(
{
"stopped": bool(cancelled),
"cancelled": cancelled,
"timers_paused": True,
}
)
async def handle_stop(request: web.Request) -> web.Response:
"""POST /api/sessions/{session_id}/stop — cancel a running execution.
@@ -416,7 +470,7 @@ def register_routes(app: web.Application) -> None:
app.router.add_post("/api/sessions/{session_id}/chat", handle_chat)
app.router.add_post("/api/sessions/{session_id}/queen-context", handle_queen_context)
app.router.add_post("/api/sessions/{session_id}/worker-input", handle_worker_input)
app.router.add_post("/api/sessions/{session_id}/pause", handle_stop)
app.router.add_post("/api/sessions/{session_id}/pause", handle_pause)
app.router.add_post("/api/sessions/{session_id}/resume", handle_resume)
app.router.add_post("/api/sessions/{session_id}/stop", handle_stop)
app.router.add_post("/api/sessions/{session_id}/cancel-queen", handle_cancel_queen)
+78 -8
View File
@@ -124,6 +124,9 @@ async def handle_create_session(request: web.Request) -> web.Response:
session_id = body.get("session_id")
model = body.get("model")
initial_prompt = body.get("initial_prompt")
# When set, the queen writes conversations to this existing session's directory
# so the full history accumulates in one place across server restarts.
queen_resume_from = body.get("queen_resume_from")
if agent_path:
try:
@@ -139,6 +142,7 @@ async def handle_create_session(request: web.Request) -> web.Response:
agent_id=agent_id,
model=model,
initial_prompt=initial_prompt,
queen_resume_from=queen_resume_from,
)
else:
# Queen-only session
@@ -146,6 +150,7 @@ async def handle_create_session(request: web.Request) -> web.Response:
session_id=session_id,
model=model,
initial_prompt=initial_prompt,
queen_resume_from=queen_resume_from,
)
except ValueError as e:
msg = str(e)
@@ -179,7 +184,12 @@ async def handle_list_live_sessions(request: web.Request) -> web.Response:
async def handle_get_live_session(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id} — get session detail."""
"""GET /api/sessions/{session_id} — get session detail.
Falls back to cold session metadata (HTTP 200 with ``cold: true``) when the
session is not alive in memory but queen conversation files exist on disk.
This lets the frontend detect a server restart and restore message history.
"""
manager = _get_manager(request)
session_id = request.match_info["session_id"]
session = manager.get_session(session_id)
@@ -190,6 +200,10 @@ async def handle_get_live_session(request: web.Request) -> web.Response:
{"session_id": session_id, "loading": True},
status=202,
)
# Check if conversation files survived on disk (post-restart scenario)
cold_info = SessionManager.get_cold_session_info(session_id)
if cold_info is not None:
return web.json_response(cold_info)
return web.json_response(
{"error": f"Session '{session_id}' not found"},
status=404,
@@ -613,15 +627,17 @@ async def handle_messages(request: web.Request) -> web.Response:
async def handle_queen_messages(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/queen-messages — get queen conversation."""
session, err = resolve_session(request)
if err:
return err
"""GET /api/sessions/{session_id}/queen-messages — get queen conversation.
queen_dir = Path.home() / ".hive" / "queen" / "session" / session.id
Reads directly from disk so it works for both live sessions and cold
(post-server-restart) sessions no live session required.
"""
session_id = request.match_info["session_id"]
queen_dir = Path.home() / ".hive" / "queen" / "session" / session_id
convs_dir = queen_dir / "conversations"
if not convs_dir.exists():
return web.json_response({"messages": []})
return web.json_response({"messages": [], "session_id": session_id})
all_messages: list[dict] = []
for node_dir in convs_dir.iterdir():
@@ -654,7 +670,58 @@ async def handle_queen_messages(request: web.Request) -> web.Response:
and not (m["role"] == "assistant" and m.get("tool_calls"))
]
return web.json_response({"messages": all_messages})
return web.json_response({"messages": all_messages, "session_id": session_id})
async def handle_session_history(request: web.Request) -> web.Response:
"""GET /api/sessions/history — all queen sessions on disk (live + cold).
Returns every session directory under ~/.hive/queen/session/, newest first.
Live sessions have ``live: true, cold: false``; sessions that survived a
server restart have ``live: false, cold: true``.
"""
manager = _get_manager(request)
live_sessions = {s.id: s for s in manager.list_sessions()}
disk_sessions = SessionManager.list_cold_sessions()
for s in disk_sessions:
if s["session_id"] in live_sessions:
live = live_sessions[s["session_id"]]
s["cold"] = False
s["live"] = True
# Fill in agent_name from live memory if meta.json wasn't written yet
if not s.get("agent_name") and live.worker_info:
s["agent_name"] = live.worker_info.name
if not s.get("agent_path") and live.worker_path:
s["agent_path"] = str(live.worker_path)
return web.json_response({"sessions": disk_sessions})
async def handle_delete_history_session(request: web.Request) -> web.Response:
"""DELETE /api/sessions/history/{session_id} — permanently remove a session.
Stops the live session (if still running) and deletes the queen session
directory from disk at ~/.hive/queen/session/{session_id}/.
This is the frontend 'delete from history' action.
"""
manager = _get_manager(request)
session_id = request.match_info["session_id"]
# Stop the live session if it exists (best-effort)
if manager.get_session(session_id):
await manager.stop_session(session_id)
# Delete the queen session directory from disk
queen_session_dir = Path.home() / ".hive" / "queen" / "session" / session_id
if queen_session_dir.exists() and queen_session_dir.is_dir():
try:
shutil.rmtree(queen_session_dir)
except OSError as e:
logger.warning("Failed to delete session directory %s: %s", queen_session_dir, e)
return web.json_response({"error": f"Failed to delete session: {e}"}, status=500)
return web.json_response({"deleted": session_id})
# ------------------------------------------------------------------
@@ -703,6 +770,9 @@ def register_routes(app: web.Application) -> None:
# Session lifecycle
app.router.add_post("/api/sessions", handle_create_session)
app.router.add_get("/api/sessions", handle_list_live_sessions)
# history must be registered before {session_id} so it takes priority
app.router.add_get("/api/sessions/history", handle_session_history)
app.router.add_delete("/api/sessions/history/{session_id}", handle_delete_history_session)
app.router.add_get("/api/sessions/{session_id}", handle_get_live_session)
app.router.add_delete("/api/sessions/{session_id}", handle_stop_session)
+221 -16
View File
@@ -45,6 +45,11 @@ class Session:
# Judge (active when worker is loaded)
judge_task: asyncio.Task | None = None
escalation_sub: str | None = None
# Session directory resumption:
# When set, _start_queen writes queen conversations to this existing session's
# directory instead of creating a new one. This lets cold-restores accumulate
# all messages in the original session folder so history is never fragmented.
queen_resume_from: str | None = None
class SessionManager:
@@ -54,10 +59,11 @@ class SessionManager:
(blocking I/O) then started on the event loop.
"""
def __init__(self, model: str | None = None) -> None:
def __init__(self, model: str | None = None, credential_store=None) -> None:
self._sessions: dict[str, Session] = {}
self._loading: set[str] = set()
self._model = model
self._credential_store = credential_store
self._lock = asyncio.Lock()
# ------------------------------------------------------------------
@@ -113,18 +119,25 @@ class SessionManager:
session_id: str | None = None,
model: str | None = None,
initial_prompt: str | None = None,
queen_resume_from: str | None = None,
) -> Session:
"""Create a new session with a queen but no worker.
The queen starts immediately with MCP coding tools.
A worker can be loaded later via load_worker().
When ``queen_resume_from`` is set the queen writes conversation messages
to that existing session's directory instead of creating a new one.
This preserves full conversation history across server restarts.
"""
session = await self._create_session_core(session_id=session_id, model=model)
session.queen_resume_from = queen_resume_from
# Start queen immediately (queen-only, no worker tools yet)
await self._start_queen(session, worker_identity=None, initial_prompt=initial_prompt)
logger.info("Session '%s' created (queen-only)", session.id)
logger.info(
"Session '%s' created (queen-only, resume_from=%s)",
session.id,
queen_resume_from,
)
return session
async def create_session_with_worker(
@@ -133,15 +146,12 @@ class SessionManager:
agent_id: str | None = None,
model: str | None = None,
initial_prompt: str | None = None,
queen_resume_from: str | None = None,
) -> Session:
"""Create a session and load a worker in one step.
Backward-compatible with the old POST /api/agents flow.
Loads the worker FIRST so the queen starts with full lifecycle
and monitoring tools available.
The session gets an auto-generated unique ID. The agent name
becomes the worker_id (used by the frontend as backendAgentId).
When ``queen_resume_from`` is set the queen writes conversation messages
to that existing session's directory instead of creating a new one.
"""
from framework.tools.queen_lifecycle_tools import build_worker_profile
@@ -150,6 +160,7 @@ class SessionManager:
# Auto-generate session ID (not the agent name)
session = await self._create_session_core(model=model)
session.queen_resume_from = queen_resume_from
try:
# Load worker FIRST (before queen) so queen gets full tools
await self._load_worker_core(
@@ -169,10 +180,6 @@ class SessionManager:
session, worker_identity=worker_identity, initial_prompt=initial_prompt
)
# Health judge disabled for simplicity.
# if agent_path.name != "hive_coder" and session.worker_runtime:
# await self._start_judge(session, session.runner._storage_path)
except Exception:
# If anything fails, tear down the session
await self.stop_session(session.id)
@@ -219,6 +226,7 @@ class SessionManager:
model=resolved_model,
interactive=False,
skip_credential_validation=True,
credential_store=self._credential_store,
),
)
@@ -399,7 +407,12 @@ class SessionManager:
worker_identity: str | None,
initial_prompt: str | None = None,
) -> None:
"""Start the queen executor for a session."""
"""Start the queen executor for a session.
When ``session.queen_resume_from`` is set, queen conversation messages
are written to the ORIGINAL session's directory so the full conversation
history accumulates in one place across server restarts.
"""
from framework.agents.hive_coder.agent import (
queen_goal,
queen_graph as _queen_graph,
@@ -409,9 +422,41 @@ class SessionManager:
from framework.runtime.core import Runtime
hive_home = Path.home() / ".hive"
queen_dir = hive_home / "queen" / "session" / session.id
# Determine which session directory to use for queen storage.
# When queen_resume_from is set we write to the ORIGINAL session's
# directory so that all messages accumulate in one place.
storage_session_id = session.queen_resume_from or session.id
queen_dir = hive_home / "queen" / "session" / storage_session_id
queen_dir.mkdir(parents=True, exist_ok=True)
# Always write/update session metadata so history sidebar has correct
# agent name, path, and last-active timestamp (important so the original
# session directory sorts as "most recent" after a cold-restore resume).
_meta_path = queen_dir / "meta.json"
try:
_agent_name = (
session.worker_info.name
if session.worker_info
else (
str(session.worker_path.name).replace("_", " ").title()
if session.worker_path
else None
)
)
_meta_path.write_text(
json.dumps(
{
"agent_name": _agent_name,
"agent_path": str(session.worker_path) if session.worker_path else None,
"created_at": time.time(),
}
),
encoding="utf-8",
)
except OSError:
pass
# Register MCP coding tools
queen_registry = ToolRegistry()
import framework.agents.hive_coder as _hive_coder_pkg
@@ -772,6 +817,166 @@ class SessionManager:
def list_sessions(self) -> list[Session]:
return list(self._sessions.values())
# ------------------------------------------------------------------
# Cold session helpers (disk-only, no live runtime required)
# ------------------------------------------------------------------
@staticmethod
def get_cold_session_info(session_id: str) -> dict | None:
"""Return disk metadata for a session that is no longer live in memory.
Checks whether queen conversation files exist at
~/.hive/queen/session/{session_id}/conversations/. Returns None when
no data is found so callers can fall through to a 404.
"""
queen_dir = Path.home() / ".hive" / "queen" / "session" / session_id
convs_dir = queen_dir / "conversations"
if not convs_dir.exists():
return None
# Check whether any message part files are actually present
has_messages = False
try:
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir():
continue
parts_dir = node_dir / "parts"
if parts_dir.exists() and any(f.suffix == ".json" for f in parts_dir.iterdir()):
has_messages = True
break
except OSError:
pass
try:
created_at = queen_dir.stat().st_ctime
except OSError:
created_at = 0.0
# Read extra metadata written at session start
agent_name: str | None = None
agent_path: str | None = None
meta_path = queen_dir / "meta.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
agent_name = meta.get("agent_name")
agent_path = meta.get("agent_path")
created_at = meta.get("created_at") or created_at
except (json.JSONDecodeError, OSError):
pass
return {
"session_id": session_id,
"cold": True,
"live": False,
"has_messages": has_messages,
"created_at": created_at,
"agent_name": agent_name,
"agent_path": agent_path,
}
@staticmethod
def list_cold_sessions() -> list[dict]:
"""Return metadata for every queen session directory on disk, newest first."""
queen_sessions_dir = Path.home() / ".hive" / "queen" / "session"
if not queen_sessions_dir.exists():
return []
results: list[dict] = []
try:
entries = sorted(
queen_sessions_dir.iterdir(),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
except OSError:
return []
for d in entries:
if not d.is_dir():
continue
try:
created_at = d.stat().st_ctime
except OSError:
created_at = 0.0
agent_name: str | None = None
agent_path: str | None = None
meta_path = d / "meta.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
agent_name = meta.get("agent_name")
agent_path = meta.get("agent_path")
created_at = meta.get("created_at") or created_at
except (json.JSONDecodeError, OSError):
pass
# Build a quick preview of the last human/assistant exchange.
# We read all conversation parts, filter to client-facing messages,
# and return the last assistant message content as a snippet.
last_message: str | None = None
message_count: int = 0
convs_dir = d / "conversations"
if convs_dir.exists():
try:
all_parts: list[dict] = []
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir():
continue
parts_dir = node_dir / "parts"
if not parts_dir.exists():
continue
for part_file in sorted(parts_dir.iterdir()):
if part_file.suffix != ".json":
continue
try:
part = json.loads(part_file.read_text(encoding="utf-8"))
part.setdefault("created_at", part_file.stat().st_mtime)
all_parts.append(part)
except (json.JSONDecodeError, OSError):
continue
# Filter to client-facing messages only
client_msgs = [
p
for p in all_parts
if not p.get("is_transition_marker")
and p.get("role") != "tool"
and not (p.get("role") == "assistant" and p.get("tool_calls"))
]
client_msgs.sort(key=lambda m: m.get("created_at", m.get("seq", 0)))
message_count = len(client_msgs)
# Last assistant message as preview snippet
for msg in reversed(client_msgs):
content = msg.get("content") or ""
if isinstance(content, list):
# Anthropic-style content blocks
content = " ".join(
b.get("text", "")
for b in content
if isinstance(b, dict) and b.get("type") == "text"
)
if content and msg.get("role") == "assistant":
last_message = content[:120].strip()
break
except OSError:
pass
results.append(
{
"session_id": d.name,
"cold": True, # caller overrides for live sessions
"live": False,
"has_messages": convs_dir.exists() and message_count > 0,
"created_at": created_at,
"agent_name": agent_name,
"agent_path": agent_path,
"last_message": last_message,
"message_count": message_count,
}
)
return results
async def shutdown_all(self) -> None:
"""Gracefully stop all sessions. Called on server shutdown."""
session_ids = list(self._sessions.keys())
+26 -13
View File
@@ -74,6 +74,7 @@ class MockStream:
is_awaiting_input: bool = False
_execution_tasks: dict = field(default_factory=dict)
_active_executors: dict = field(default_factory=dict)
active_execution_ids: set = field(default_factory=set)
async def cancel_execution(self, execution_id: str) -> bool:
return execution_id in self._execution_tasks
@@ -117,6 +118,9 @@ class MockRuntime:
async def inject_input(self, node_id, content, graph_id=None, *, is_client_input=False):
return True
def pause_timers(self):
pass
async def get_goal_progress(self):
return {"progress": 0.5, "criteria": []}
@@ -537,18 +541,8 @@ class TestExecution:
assert resp.status == 400
@pytest.mark.asyncio
async def test_pause_not_found(self):
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/sessions/test_agent/pause",
json={"execution_id": "nonexistent"},
)
assert resp.status == 404
@pytest.mark.asyncio
async def test_pause_missing_execution_id(self):
async def test_pause_no_active_executions(self):
"""Pause with no active executions returns stopped=False."""
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
@@ -556,7 +550,26 @@ class TestExecution:
"/api/sessions/test_agent/pause",
json={},
)
assert resp.status == 400
assert resp.status == 200
data = await resp.json()
assert data["stopped"] is False
assert data["cancelled"] == []
assert data["timers_paused"] is True
@pytest.mark.asyncio
async def test_pause_does_not_cancel_queen(self):
"""Pause should stop the worker but leave the queen running."""
session = _make_session()
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/sessions/test_agent/pause",
json={},
)
assert resp.status == 200
# Queen's cancel_current_turn should NOT have been called
queen_node = session.queen_executor.node_registry["queen"]
queen_node.cancel_current_turn.assert_not_called()
@pytest.mark.asyncio
async def test_goal_progress(self):
+2 -2
View File
@@ -270,10 +270,10 @@ def _edit_test_code(code: str) -> str:
try:
# Open editor
subprocess.run([editor, temp_path], check=True)
subprocess.run([editor, temp_path], check=True, encoding="utf-8")
# Read edited code
with open(temp_path) as f:
with open(temp_path, encoding="utf-8") as f:
return f.read()
except subprocess.CalledProcessError:
print("Editor failed, keeping original code")
+2
View File
@@ -190,6 +190,7 @@ def cmd_test_run(args: argparse.Namespace) -> int:
try:
result = subprocess.run(
cmd,
encoding="utf-8",
env=env,
timeout=600, # 10 minute timeout
)
@@ -248,6 +249,7 @@ def cmd_test_debug(args: argparse.Namespace) -> int:
try:
result = subprocess.run(
cmd,
encoding="utf-8",
env=env,
timeout=120, # 2 minute timeout for single test
)
+7 -33
View File
@@ -256,7 +256,7 @@ class AdenTUI(App):
"""Override to use native `open` for file:// URLs on macOS."""
if url.startswith("file://") and platform.system() == "Darwin":
path = url.removeprefix("file://")
subprocess.Popen(["open", path])
subprocess.Popen(["open", path], encoding="utf-8")
else:
super().open_url(url, new_tab=new_tab)
@@ -1643,46 +1643,20 @@ class AdenTUI(App):
self.notify(f"Logs {mode}", severity="information", timeout=2)
def action_pause_execution(self) -> None:
"""Immediately pause execution by cancelling task (bound to Ctrl+Z)."""
"""Immediately pause execution by cancelling all running tasks (bound to Ctrl+Z)."""
if self.chat_repl is None or self.runtime is None:
return
try:
if not self.chat_repl._current_exec_id:
if self.runtime.cancel_all_tasks(self.chat_repl._agent_loop):
self.chat_repl._current_exec_id = None
self.notify(
"No active execution to pause",
"All executions stopped",
severity="information",
timeout=3,
)
return
task_cancelled = False
all_streams = []
active_reg = self.runtime.get_graph_registration(self.runtime.active_graph_id)
if active_reg:
all_streams.extend(active_reg.streams.values())
for gid in self.runtime.list_graphs():
if gid == self.runtime.active_graph_id:
continue
reg = self.runtime.get_graph_registration(gid)
if reg:
all_streams.extend(reg.streams.values())
for stream in all_streams:
exec_id = self.chat_repl._current_exec_id
task = stream._execution_tasks.get(exec_id)
if task and not task.done():
task.cancel()
task_cancelled = True
self.notify(
"Execution paused - state saved",
severity="information",
timeout=3,
)
break
if not task_cancelled:
else:
self.notify(
"Execution already completed",
"No active executions",
severity="information",
timeout=2,
)
+17 -27
View File
@@ -488,7 +488,7 @@ class ChatRepl(Vertical):
if not state_file.exists():
continue
with open(state_file) as f:
with open(state_file, encoding="utf-8") as f:
state = json.load(f)
status = state.get("status", "").lower()
@@ -547,7 +547,7 @@ class ChatRepl(Vertical):
# Read session state
try:
with open(state_file) as f:
with open(state_file, encoding="utf-8") as f:
state = json.load(f)
# Track this session for /resume <number> lookup
@@ -599,7 +599,7 @@ class ChatRepl(Vertical):
try:
import json
with open(state_file) as f:
with open(state_file, encoding="utf-8") as f:
state = json.load(f)
# Basic info
@@ -640,7 +640,7 @@ class ChatRepl(Vertical):
# Load and show checkpoints
for i, cp_file in enumerate(checkpoint_files[-5:], 1): # Last 5
try:
with open(cp_file) as f:
with open(cp_file, encoding="utf-8") as f:
cp_data = json.load(f)
cp_id = cp_data.get("checkpoint_id", cp_file.stem)
@@ -687,7 +687,7 @@ class ChatRepl(Vertical):
import json
with open(state_file) as f:
with open(state_file, encoding="utf-8") as f:
state = json.load(f)
# Resume from session state (not checkpoint)
@@ -868,27 +868,17 @@ class ChatRepl(Vertical):
self._write_history(f"[dim]{traceback.format_exc()}[/dim]")
async def _cmd_pause(self) -> None:
"""Immediately pause execution by cancelling task (same as Ctrl+Z)."""
# Check if there's a current execution
if not self._current_exec_id:
self._write_history("[bold yellow]No active execution to pause[/bold yellow]")
self._write_history(" Start an execution first, then use /pause during execution")
return
# Find and cancel the execution task - executor will catch and save state
task_cancelled = False
for stream in self.runtime._streams.values():
exec_id = self._current_exec_id
task = stream._execution_tasks.get(exec_id)
if task and not task.done():
task.cancel()
task_cancelled = True
self._write_history("[bold green]⏸ Execution paused - state saved[/bold green]")
self._write_history(" Resume later with: [bold]/resume[/bold]")
break
if not task_cancelled:
self._write_history("[bold yellow]Execution already completed[/bold yellow]")
"""Immediately pause execution by cancelling all running tasks (same as Ctrl+Z)."""
future = asyncio.run_coroutine_threadsafe(
self.runtime.cancel_all_tasks_async(), self._agent_loop
)
result = await asyncio.wrap_future(future)
if result:
self._current_exec_id = None
self._write_history("[bold green]⏸ All executions stopped[/bold green]")
self._write_history(" Resume later with: [bold]/resume[/bold]")
else:
self._write_history("[bold yellow]No active executions[/bold yellow]")
async def _cmd_coder(self, reason: str = "") -> None:
"""User-initiated escalation to Hive Coder."""
@@ -1112,7 +1102,7 @@ class ChatRepl(Vertical):
continue
try:
with open(state_file) as f:
with open(state_file, encoding="utf-8") as f:
state = json.load(f)
status = state.get("status", "").lower()
@@ -38,6 +38,7 @@ def _linux_file_dialog() -> subprocess.CompletedProcess | None:
"--title=Select a PDF file",
"--file-filter=PDF files (*.pdf)|*.pdf",
],
encoding="utf-8",
capture_output=True,
text=True,
timeout=300,
@@ -54,6 +55,7 @@ def _linux_file_dialog() -> subprocess.CompletedProcess | None:
".",
"PDF files (*.pdf)",
],
encoding="utf-8",
capture_output=True,
text=True,
timeout=300,
@@ -79,6 +81,7 @@ def _pick_pdf_subprocess() -> Path | None:
'POSIX path of (choose file of type {"com.adobe.pdf"} '
'with prompt "Select a PDF file")',
],
encoding="utf-8",
capture_output=True,
text=True,
timeout=300,
@@ -93,6 +96,7 @@ def _pick_pdf_subprocess() -> Path | None:
)
result = subprocess.run(
["powershell", "-NoProfile", "-Command", ps_script],
encoding="utf-8",
capture_output=True,
text=True,
timeout=300,
@@ -199,10 +199,11 @@ def _copy_to_clipboard(text: str) -> None:
"""Copy text to system clipboard using platform-native tools."""
try:
if sys.platform == "darwin":
subprocess.run(["pbcopy"], input=text.encode(), check=True, timeout=5)
subprocess.run(["pbcopy"], encoding="utf-8", input=text.encode(), check=True, timeout=5)
elif sys.platform == "win32":
subprocess.run(
["clip.exe"],
encoding="utf-8",
input=text.encode("utf-16le"),
check=True,
timeout=5,
@@ -211,6 +212,7 @@ def _copy_to_clipboard(text: str) -> None:
try:
subprocess.run(
["xclip", "-selection", "clipboard"],
encoding="utf-8",
input=text.encode(),
check=True,
timeout=5,
@@ -218,6 +220,7 @@ def _copy_to_clipboard(text: str) -> None:
except (subprocess.SubprocessError, FileNotFoundError):
subprocess.run(
["xsel", "--clipboard", "--input"],
encoding="utf-8",
input=text.encode(),
check=True,
timeout=5,
+12 -3
View File
@@ -13,12 +13,13 @@ export const sessionsApi = {
// --- Session lifecycle ---
/** Create a session. If agentPath is provided, loads worker in one step. */
create: (agentPath?: string, agentId?: string, model?: string, initialPrompt?: string) =>
create: (agentPath?: string, agentId?: string, model?: string, initialPrompt?: string, queenResumeFrom?: string) =>
api.post<LiveSession>("/sessions", {
agent_path: agentPath,
agent_id: agentId,
model,
initial_prompt: initialPrompt,
queen_resume_from: queenResumeFrom || undefined,
}),
/** List all active sessions. */
@@ -66,9 +67,17 @@ export const sessionsApi = {
graphs: (sessionId: string) =>
api.get<{ graphs: string[] }>(`/sessions/${sessionId}/graphs`),
/** Get queen conversation history for a session. */
/** Get queen conversation history for a session (works for cold/post-restart sessions too). */
queenMessages: (sessionId: string) =>
api.get<{ messages: Message[] }>(`/sessions/${sessionId}/queen-messages`),
api.get<{ messages: Message[]; session_id: string }>(`/sessions/${sessionId}/queen-messages`),
/** List all queen sessions on disk — live + cold (post-restart). */
history: () =>
api.get<{ sessions: Array<{ session_id: string; cold: boolean; live: boolean; has_messages: boolean; created_at: number; agent_name?: string | null; agent_path?: string | null }> }>("/sessions/history"),
/** Permanently delete a history session (stops live session + removes disk files). */
deleteHistory: (sessionId: string) =>
api.delete<{ deleted: string }>(`/sessions/history/${sessionId}`),
// --- Worker session browsing (persisted execution runs) ---
+2
View File
@@ -21,6 +21,8 @@ export interface LiveSession {
export interface LiveSessionDetail extends LiveSession {
entry_points?: EntryPoint[];
graphs?: string[];
/** True when the session exists on disk but is not live (server restarted). */
cold?: boolean;
}
export interface EntryPoint {
@@ -400,17 +400,19 @@ export default function CredentialsModal({
<Pencil className="w-3 h-3" />
</button>
)}
<button
onClick={() => {
setDeletingId(deletingId === row.id ? null : row.id);
if (editingId) { setEditingId(null); setInputValue(""); }
}}
disabled={saving}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Delete credential"
>
<Trash2 className="w-3 h-3" />
</button>
{!(row.adenSupported && row.id !== "aden_api_key") && (
<button
onClick={() => {
setDeletingId(deletingId === row.id ? null : row.id);
if (editingId) { setEditingId(null); setInputValue(""); }
}}
disabled={saving}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Delete credential"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
) : row.adenSupported && !adenPlatformConnected && row.id !== "aden_api_key" ? (
<span className="text-[11px] text-muted-foreground italic flex-shrink-0">
@@ -0,0 +1,431 @@
/**
* HistorySidebar — persistent ChatGPT-style session history sidebar.
*
* Shown on both the Home page and the Workspace. Clicking a session fires
* `onOpen(sessionId, agentPath)` so the caller decides what to do (navigate
* to workspace on Home, open/switch tab on Workspace).
*
* Labels (user-visible names) are stored purely in localStorage — backend
* session IDs are never touched.
*
* Session deduplication: the backend may have multiple session directories
* for the same agent (cold restarts create new directories). We deduplicate
* by agent_path and show only the most-recent session per agent so the
* history list stays clean.
*/
import { useState, useEffect, useRef, useCallback } from "react";
import { ChevronLeft, ChevronRight, Clock, Bot, Loader2, MoreHorizontal, Pencil, Trash2, Check, X } from "lucide-react";
import { sessionsApi } from "@/api/sessions";
// ── Types ─────────────────────────────────────────────────────────────────────
export type HistorySession = {
session_id: string;
cold: boolean;
live: boolean;
has_messages: boolean;
created_at: number;
agent_name?: string | null;
agent_path?: string | null;
/** Snippet of the last assistant message — for sidebar preview. */
last_message?: string | null;
/** Total number of client-facing messages in this session. */
message_count?: number;
};
const LABEL_STORE_KEY = "hive:history-labels";
function loadLabelStore(): Record<string, string> {
try {
const raw = localStorage.getItem(LABEL_STORE_KEY);
return raw ? (JSON.parse(raw) as Record<string, string>) : {};
} catch {
return {};
}
}
function saveLabelStore(store: Record<string, string>) {
try {
localStorage.setItem(LABEL_STORE_KEY, JSON.stringify(store));
} catch { }
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function defaultLabel(s: HistorySession, index: number): string {
if (s.agent_name) return s.agent_name;
if (s.agent_path) {
const base = s.agent_path.replace(/\/$/, "").split("/").pop() || s.agent_path;
return base
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
return `New Agent${index > 0 ? ` #${index + 1}` : ""}`;
}
function formatDateTime(createdAt: number, sessionId: string): string {
// Prefer timestamp embedded in session_id: session_YYYYMMDD_HHMMSS_xxx
const match = sessionId.match(/^session_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
const d = match
? new Date(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +match[6])
: new Date(createdAt * 1000);
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Deduplicate sessions by agent_path — keep only the most recent session
* per agent. Sessions are already sorted newest-first by the backend.
* Sessions without an agent_path (new-agent / queen-only) are kept individually.
*/
function deduplicateByAgent(sessions: HistorySession[]): HistorySession[] {
const seen = new Set<string>();
const result: HistorySession[] = [];
for (const s of sessions) {
// Group key: use agent_path when present, otherwise use session_id (unique)
const key = s.agent_path ? s.agent_path.replace(/\/$/, "") : `__no_agent__${s.session_id}`;
if (!seen.has(key)) {
seen.add(key);
result.push(s);
}
// Additional sessions for the same agent are silently skipped
}
return result;
}
function groupByDate(sessions: HistorySession[]): { label: string; items: HistorySession[] }[] {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterday = today - 86_400_000;
const weekAgo = today - 7 * 86_400_000;
const groups: { label: string; items: HistorySession[] }[] = [
{ label: "Today", items: [] },
{ label: "Yesterday", items: [] },
{ label: "Last 7 days", items: [] },
{ label: "Older", items: [] },
];
for (const s of sessions) {
const d = new Date(s.created_at * 1000);
const dayTs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
if (dayTs >= today) groups[0].items.push(s);
else if (dayTs >= yesterday) groups[1].items.push(s);
else if (dayTs >= weekAgo) groups[2].items.push(s);
else groups[3].items.push(s);
}
return groups.filter((g) => g.items.length > 0);
}
// ── Row component ─────────────────────────────────────────────────────────────
interface RowProps {
session: HistorySession;
label: string;
index: number;
isActive: boolean;
isLive: boolean;
onOpen: () => void;
onRename: (newLabel: string) => void;
onDelete: () => void;
}
function HistoryRow({ session: s, label, isActive, isLive, onOpen, onRename, onDelete }: RowProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [draftLabel, setDraftLabel] = useState(label);
const menuRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [menuOpen]);
useEffect(() => {
if (renaming) {
setDraftLabel(label);
requestAnimationFrame(() => inputRef.current?.select());
}
}, [renaming, label]);
const commitRename = () => {
const trimmed = draftLabel.trim();
if (trimmed) onRename(trimmed);
setRenaming(false);
};
const dateStr = formatDateTime(s.created_at, s.session_id);
return (
<div
className={`group relative flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors ${isActive
? "bg-primary/10 border-l-2 border-primary"
: "border-l-2 border-transparent hover:bg-muted/40"
}`}
onClick={() => { if (!renaming) onOpen(); }}
>
<Bot className="w-3.5 h-3.5 flex-shrink-0 mt-[3px] text-muted-foreground/40 group-hover:text-muted-foreground/70 transition-colors" />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
value={draftLabel}
onChange={(e) => setDraftLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setRenaming(false);
}}
className="flex-1 min-w-0 text-[11px] bg-muted/60 border border-border/50 rounded px-1.5 py-0.5 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<button onClick={commitRename} className="p-0.5 text-primary hover:text-primary/80">
<Check className="w-3 h-3" />
</button>
<button onClick={() => setRenaming(false)} className="p-0.5 text-muted-foreground hover:text-foreground">
<X className="w-3 h-3" />
</button>
</div>
) : (
<>
<div className={`text-[11px] font-medium truncate leading-tight ${isActive ? "text-foreground" : "text-foreground/80"}`}>
{label}
</div>
{/* Message preview — most recent assistant message */}
{s.last_message && (
<div className="text-[10px] text-muted-foreground/50 mt-0.5 leading-tight line-clamp-2 break-words">
{s.last_message}
</div>
)}
<div className="flex items-center gap-1.5 mt-0.5">
<div className="text-[10px] text-muted-foreground/40">{dateStr}</div>
{(s.message_count ?? 0) > 0 && (
<span className="text-[9px] text-muted-foreground/30">· {s.message_count} msgs</span>
)}
</div>
{isLive && (
<span className="text-[9px] text-emerald-500/80 font-semibold uppercase tracking-wide">live</span>
)}
</>
)}
</div>
{/* 3-dot button — visible on row hover */}
{!renaming && (
<div className="relative flex-shrink-0" ref={menuRef} onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setMenuOpen((o) => !o)}
className={`p-0.5 rounded transition-colors text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 ${menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
title="More options"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{menuOpen && (
<div className="absolute right-0 top-5 z-50 w-36 rounded-lg border border-border/60 bg-card shadow-xl shadow-black/30 overflow-hidden py-1">
<button
onClick={() => { setMenuOpen(false); setRenaming(true); }}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-foreground hover:bg-muted/60 transition-colors"
>
<Pencil className="w-3 h-3 text-muted-foreground" />
Rename
</button>
<button
onClick={() => { setMenuOpen(false); onDelete(); }}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3 h-3" />
Delete
</button>
</div>
)}
</div>
)}
</div>
);
}
// ── Main sidebar component ────────────────────────────────────────────────────
interface HistorySidebarProps {
/** Called when a history session is clicked. */
onOpen: (sessionId: string, agentPath?: string | null, agentName?: string | null) => void;
/** session_ids of tabs already open (for highlighting). */
openSessionIds?: string[];
/** session_id of the currently active/viewed session (live backend ID). */
activeSessionId?: string | null;
/** historySourceId of the active session — the original cold session ID before revive,
* stays stable even after the backend creates a new live session on cold-restore. */
activeHistorySourceId?: string | null;
/** Increment this to force a refresh of the session list. */
refreshKey?: number;
}
export default function HistorySidebar({ onOpen, openSessionIds = [], activeSessionId, activeHistorySourceId, refreshKey }: HistorySidebarProps) {
const [collapsed, setCollapsed] = useState(false);
// Raw sessions from the backend (may contain duplicates per agent)
const [rawSessions, setRawSessions] = useState<HistorySession[]>([]);
const [loading, setLoading] = useState(false);
const [labels, setLabels] = useState<Record<string, string>>(loadLabelStore);
const refresh = useCallback(() => {
setLoading(true);
sessionsApi
.history()
.then((r) => setRawSessions(r.sessions))
.catch(() => { })
.finally(() => setLoading(false));
}, []);
// Refresh on mount and whenever the parent forces a refresh
useEffect(() => {
refresh();
}, [refresh, refreshKey]);
// Refresh when the browser tab regains visibility
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === "visible") refresh();
};
document.addEventListener("visibilitychange", handleVisibility);
return () => document.removeEventListener("visibilitychange", handleVisibility);
}, [refresh]);
const handleRename = (sessionId: string, newLabel: string) => {
const next = { ...labels, [sessionId]: newLabel };
setLabels(next);
saveLabelStore(next);
};
const handleDelete = (sessionId: string) => {
// Optimistically remove from in-memory list immediately
setRawSessions((prev) => prev.filter((s) => s.session_id !== sessionId));
const next = { ...labels };
delete next[sessionId];
setLabels(next);
saveLabelStore(next);
// Permanently delete session files from disk (fire-and-forget)
sessionsApi.deleteHistory(sessionId).catch(() => {
// Soft failure — the entry is already removed from the UI.
// The file may linger on disk, but won't appear in the next refresh
// because it's been removed from rawSessions.
});
};
// ── Deduplicate & render ────────────────────────────────────────────────────
// Deduplicate: show only the most-recent session per agent_path.
// rawSessions is already sorted newest-first by the backend.
const sessions = deduplicateByAgent(rawSessions);
const groups = groupByDate(sessions);
return (
<div
className={`flex-shrink-0 flex flex-col bg-card/20 border-r border-border/30 transition-[width] duration-200 overflow-hidden ${collapsed ? "w-[44px]" : "w-[220px]"
}`}
>
{/* Header */}
<div
className={`flex items-center border-b border-border/20 flex-shrink-0 h-10 ${collapsed ? "justify-center" : "px-3 gap-2"
}`}
>
{!collapsed && (
<span className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider flex-1">
History
</span>
)}
<button
onClick={() => setCollapsed((o) => !o)}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
title={collapsed ? "Expand history" : "Collapse history"}
>
{collapsed ? (
<ChevronRight className="w-3.5 h-3.5" />
) : (
<ChevronLeft className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Expanded list */}
{!collapsed && (
<div className="flex-1 overflow-y-auto min-h-0">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground/40" />
</div>
) : sessions.length === 0 ? (
<div className="px-4 py-12 text-center text-[11px] text-muted-foreground/40 leading-relaxed">
No previous
<br />
sessions yet
</div>
) : (
groups.map(({ label: groupLabel, items }) => (
<div key={groupLabel}>
<p className="px-3 pt-4 pb-1 text-[10px] font-semibold text-muted-foreground/35 uppercase tracking-wider">
{groupLabel}
</p>
{items.map((s, idx) => {
const customLabel = labels[s.session_id];
const computedLabel = customLabel || defaultLabel(s, idx);
const isActive =
s.session_id === activeSessionId ||
s.session_id === activeHistorySourceId;
// Mark as live if the backend flagged it OR if it's currently open in a tab
const isLive = s.live || openSessionIds.includes(s.session_id);
return (
<HistoryRow
key={s.session_id}
session={s}
label={computedLabel}
index={idx}
isActive={isActive}
isLive={isLive}
onOpen={() => onOpen(s.session_id, s.agent_path, s.agent_name)}
onRename={(nl) => handleRename(s.session_id, nl)}
onDelete={() => handleDelete(s.session_id)}
/>
);
})}
</div>
))
)}
</div>
)}
{/* Collapsed icon strip */}
{collapsed && (
<div className="flex-1 overflow-y-auto min-h-0 flex flex-col items-center py-2 gap-0.5">
{sessions.slice(0, 30).map((s) => {
const isLive = s.live || openSessionIds.includes(s.session_id);
return (
<button
key={s.session_id}
onClick={() => { setCollapsed(false); onOpen(s.session_id, s.agent_path, s.agent_name); }}
className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground/40 hover:text-foreground hover:bg-muted/50 transition-colors relative"
title={labels[s.session_id] || defaultLabel(s, 0)}
>
<Clock className="w-3 h-3" />
{isLive && (
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-emerald-500" />
)}
</button>
);
})}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ import type { GraphNode } from "@/components/AgentGraph";
export const TAB_STORAGE_KEY = "hive:workspace-tabs";
export interface PersistedTabState {
tabs: Array<{ id: string; agentType: string; label: string; backendSessionId?: string }>;
tabs: Array<{ id: string; agentType: string; tabKey?: string; label: string; backendSessionId?: string; historySourceId?: string }>;
activeSessionByAgent: Record<string, string>;
activeWorker: string;
sessions?: Record<string, { messages: ChatMessage[]; graphNodes: GraphNode[] }>;
+15 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Crown, Mail, Briefcase, Shield, Search, Newspaper, ArrowRight, Hexagon, Send, Bot } from "lucide-react";
import { Crown, Mail, Briefcase, Shield, Search, Newspaper, ArrowRight, Hexagon, Send, Bot, Radar, Reply, DollarSign, MapPin, Calendar, UserPlus, Twitter } from "lucide-react";
import TopBar from "@/components/TopBar";
import type { LucideIcon } from "lucide-react";
import { agentsApi } from "@/api/agents";
@@ -14,6 +14,13 @@ const AGENT_ICONS: Record<string, LucideIcon> = {
vulnerability_assessment: Shield,
deep_research_agent: Search,
tech_news_reporter: Newspaper,
competitive_intel_agent: Radar,
email_reply_agent: Reply,
hubspot_revenue_leak_detector: DollarSign,
local_business_extractor: MapPin,
meeting_scheduler: Calendar,
sdr_agent: UserPlus,
twitter_news_agent: Twitter,
};
const AGENT_COLORS: Record<string, string> = {
@@ -22,6 +29,13 @@ const AGENT_COLORS: Record<string, string> = {
vulnerability_assessment: "hsl(15,70%,52%)",
deep_research_agent: "hsl(210,70%,55%)",
tech_news_reporter: "hsl(270,60%,55%)",
competitive_intel_agent: "hsl(190,70%,45%)",
email_reply_agent: "hsl(45,80%,55%)",
hubspot_revenue_leak_detector: "hsl(145,60%,42%)",
local_business_extractor: "hsl(350,65%,55%)",
meeting_scheduler: "hsl(220,65%,55%)",
sdr_agent: "hsl(165,55%,45%)",
twitter_news_agent: "hsl(200,85%,55%)",
};
function agentSlug(path: string): string {
+2 -2
View File
@@ -41,11 +41,11 @@ export default function MyAgents() {
const idleCount = agents.length - activeCount;
return (
<div className="min-h-screen bg-background flex flex-col">
<div className="h-screen bg-background flex flex-col overflow-hidden">
<TopBar />
{/* Content */}
<div className="flex-1 p-6 md:p-10 max-w-5xl mx-auto w-full">
<div className="flex-1 p-6 md:p-10 max-w-5xl mx-auto w-full overflow-y-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-xl font-semibold text-foreground">My Agents</h1>
+473 -96
View File
@@ -8,7 +8,6 @@ import TopBar from "@/components/TopBar";
import { TAB_STORAGE_KEY, loadPersistedTabs, savePersistedTabs, type PersistedTabState } from "@/lib/tab-persistence";
import NodeDetailPanel from "@/components/NodeDetailPanel";
import CredentialsModal, { type Credential, createFreshCredentials, cloneCredentials, allRequiredCredentialsMet, clearCredentialCache } from "@/components/CredentialsModal";
import { agentsApi } from "@/api/agents";
import { executionApi } from "@/api/execution";
import { graphsApi } from "@/api/graphs";
@@ -21,6 +20,13 @@ import { ApiError } from "@/api/client";
const makeId = () => Math.random().toString(36).slice(2, 9);
/**
* Strip the instance suffix added when multiple tabs share the same agentType.
* e.g. "exports/deep_research::abc123" → "exports/deep_research"
* First-instance keys (no "::") are returned unchanged.
*/
const baseAgentType = (key: string): string => key.split("::")[0];
/** Format seconds into a compact countdown string. */
function formatCountdown(totalSecs: number): string {
const h = Math.floor(totalSecs / 3600);
@@ -56,11 +62,19 @@ function TimerCountdown({ initialSeconds }: { initialSeconds: number }) {
interface Session {
id: string;
agentType: string;
/** The key used in sessionsByAgent / agentStates for this specific tab instance.
* Equals agentType for the first tab; equals "agentType::frontendSessionId" for
* additional tabs opened for the same agent so each gets its own isolated slot. */
tabKey?: string;
label: string;
messages: ChatMessage[];
graphNodes: GraphNode[];
credentials: Credential[];
backendSessionId?: string;
/** The cold history session ID this tab was originally opened from (if any).
* Used to detect "already open" even after backendSessionId is updated to a
* new live session ID when the cold session is revived. */
historySourceId?: string;
}
function createSession(agentType: string, label: string, existingCredentials?: Credential[]): Session {
@@ -301,6 +315,9 @@ export default function Workspace() {
const rawAgent = searchParams.get("agent") || "new-agent";
const hasExplicitAgent = searchParams.has("agent");
const initialPrompt = searchParams.get("prompt") || "";
// ?session= param: when navigating from the home history sidebar, this
// carries the backendSessionId to open as a tab on mount.
const initialSessionId = searchParams.get("session") || "";
// When submitting a new prompt from home for "new-agent", use a unique key
// so each prompt gets its own tab instead of overwriting the previous one.
@@ -317,10 +334,15 @@ export default function Workspace() {
if (persisted) {
for (const tab of persisted.tabs) {
if (!initial[tab.agentType]) initial[tab.agentType] = [];
// tabKey is the actual key used in sessionsByAgent (may contain "::" suffix).
// Fall back to agentType for tabs persisted before this field was added.
const tabKey = tab.tabKey || tab.agentType;
if (!initial[tabKey]) initial[tabKey] = [];
const session = createSession(tab.agentType, tab.label);
session.id = tab.id;
session.backendSessionId = tab.backendSessionId;
session.tabKey = tab.tabKey; // restore so future persistence uses correct key
session.historySourceId = tab.historySourceId;
// Restore messages and graph from localStorage (up to 50 messages).
// If the backend session is still alive, loadAgentForType may
// append additional messages fetched from the server.
@@ -329,7 +351,7 @@ export default function Workspace() {
session.messages = cached.messages || [];
session.graphNodes = cached.graphNodes || [];
}
initial[tab.agentType].push(session);
initial[tabKey].push(session);
}
}
@@ -339,6 +361,13 @@ export default function Workspace() {
return initial;
}
// If there are already persisted tabs for this agent type, don't create
// a new one — the post-mount effect will call handleHistoryOpen if needed
// (for ?session= params coming from the home page sidebar).
if (initial[initialAgent]?.length) {
return initial;
}
// If the user submitted a new prompt from the home page, always create
// a fresh session so the prompt isn't lost into an existing session.
// initialAgent is already a unique key (e.g. "new-agent-abc123") when
@@ -352,15 +381,16 @@ export default function Workspace() {
return initial;
}
if (initial[initialAgent]?.length) {
return initial;
}
// Only create a fresh default tab when there are no persisted tabs at all.
// If ?session= was passed we intentionally do NOT create a tab here —
// handleHistoryOpen is called post-mount and does proper dedup.
if (initialAgent === "new-agent") {
initial["new-agent"] = [...(initial["new-agent"] || []), createSession("new-agent", "New Agent")];
} else {
initial[initialAgent] = [...(initial[initialAgent] || []),
createSession(initialAgent, formatAgentDisplayName(initialAgent))];
const s = createSession("new-agent", "New Agent");
initial["new-agent"] = [...(initial["new-agent"] || []), s];
} else if (!initialSessionId) {
// Only auto-create an agent tab if there's no session to restore
const s = createSession(initialAgent, formatAgentDisplayName(initialAgent));
initial[initialAgent] = [...(initial[initialAgent] || []), s];
}
return initial;
@@ -368,6 +398,17 @@ export default function Workspace() {
const [activeSessionByAgent, setActiveSessionByAgent] = useState<Record<string, string>>(() => {
const persisted = loadPersistedTabs();
// If initialSessionId maps to an already-restored tab, activate that tab
if (initialSessionId) {
for (const [tabKey, sessions] of Object.entries(sessionsByAgent)) {
const match = sessions.find(
s => s.backendSessionId === initialSessionId || s.historySourceId === initialSessionId,
);
if (match) {
return { ...(persisted?.activeSessionByAgent ?? {}), [tabKey]: match.id };
}
}
}
if (persisted) {
const restored = { ...persisted.activeSessionByAgent };
const urlSessions = sessionsByAgent[initialAgent];
@@ -387,6 +428,14 @@ export default function Workspace() {
});
const [activeWorker, setActiveWorker] = useState(() => {
// If initialSessionId maps to an already-restored tab, activate that key
if (initialSessionId) {
for (const [tabKey, sessions] of Object.entries(sessionsByAgent)) {
if (sessions.some(
s => s.backendSessionId === initialSessionId || s.historySourceId === initialSessionId,
)) return tabKey;
}
}
if (!hasExplicitAgent) {
const persisted = loadPersistedTabs();
if (persisted?.activeWorker) return persisted.activeWorker;
@@ -400,6 +449,16 @@ export default function Workspace() {
navigate("/workspace", { replace: true });
}, []);
// Post-mount: if the URL carried a ?session= param (from the home page history
// sidebar), open it via handleHistoryOpen instead of creating a tab in init state.
// This is the single canonical path — it has robust dedup (checks backendSessionId
// AND historySourceId across all in-memory tabs) and is safe to call after persisted
// state has been hydrated.
// We capture initialSessionId and related URL params in stable refs so the effect
// only fires once on mount, regardless of re-renders.
const initialSessionIdRef = useRef(initialSessionId);
const initialAgentRef = useRef(initialAgent);
const mountedRef = useRef(false);
const [credentialsOpen, setCredentialsOpen] = useState(false);
// Explicit agent path for the credentials modal — set from 424 responses
// when activeWorker doesn't match the actual agent (e.g. "new-agent" tab).
@@ -425,6 +484,12 @@ export default function Workspace() {
// arrive in the same React batch.
const turnCounterRef = useRef<Record<string, number>>({});
// Synchronous ref to suppress the queen's auto-intro SSE messages
// after a cold-restore (where we already restored the conversation from disk).
// Using a ref avoids the race condition where sessionId is set in agentState
// (opening SSE) before the suppressQueenIntro flag can be committed.
const suppressIntroRef = useRef(new Set<string>());
// --- Consolidated per-agent backend state ---
const [agentStates, setAgentStates] = useState<Record<string, AgentBackendState>>({});
@@ -448,11 +513,15 @@ export default function Workspace() {
const sessions: Record<string, { messages: ChatMessage[]; graphNodes: GraphNode[] }> = {};
for (const agentSessions of Object.values(sessionsByAgent)) {
for (const s of agentSessions) {
const tKey = s.tabKey || s.agentType;
tabs.push({
id: s.id,
agentType: s.agentType,
tabKey: s.tabKey,
label: s.label,
backendSessionId: s.backendSessionId || agentStates[s.agentType]?.sessionId || undefined,
// agentStates is keyed by tabKey (unique per tab), not by base agentType
backendSessionId: s.backendSessionId || agentStates[tKey]?.sessionId || undefined,
...(s.historySourceId ? { historySourceId: s.historySourceId } : {}),
});
sessions[s.id] = { messages: s.messages, graphNodes: s.graphNodes };
}
@@ -512,13 +581,16 @@ export default function Workspace() {
const { Framework: _fw, ...userFacing } = result;
const all = Object.values(userFacing).flat();
setDiscoverAgents(all);
}).catch(() => {});
}).catch(() => { });
}, []);
// --- Agent loading: loadAgentForType ---
const loadingRef = useRef(new Set<string>());
const loadAgentForType = useCallback(async (agentType: string) => {
if (agentType === "new-agent" || agentType.startsWith("new-agent-")) {
// agentType may be a unique composite key ("exports/foo::sessionId") for additional
// tabs — extract the real agent path for selector checks and API calls.
const agentPath = baseAgentType(agentType);
if (agentPath === "new-agent" || agentType.startsWith("new-agent-")) {
// Create a queen-only session (no worker) for agent building
updateAgentState(agentType, { loading: true, error: null, ready: false, sessionId: null });
try {
@@ -532,17 +604,68 @@ export default function Workspace() {
// Try to reconnect to stored backend session (e.g., after browser refresh)
const storedId = activeSess?.backendSessionId;
// When the server restarts the session is "cold" — conversation files
// survive on disk but there is no live runtime. Track the old ID so
// we can restore message history after creating a new session.
let coldRestoreId: string | undefined;
if (storedId) {
try {
liveSession = await sessionsApi.get(storedId);
const sessionData = await sessionsApi.get(storedId);
if (sessionData.cold) {
// Server restarted — files on disk, no live runtime
coldRestoreId = storedId;
} else {
liveSession = sessionData;
}
} catch {
// Session gone — fall through to create new
// Session gone entirely (no disk files either)
}
}
let restoredMessageCount = 0;
if (!liveSession) {
// Reconnect failed — clear stale cached messages from localStorage restore
if (storedId && activeId) {
// Fetch conversation history from disk BEFORE creating the new session.
// SKIP if messages were already pre-populated by handleHistoryOpen.
const restoreFrom = coldRestoreId ?? storedId;
const preRestoredMsgs: ChatMessage[] = [];
const alreadyHasMessages = (activeSess?.messages?.length ?? 0) > 0;
if (restoreFrom && !alreadyHasMessages) {
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(restoreFrom);
for (const m of queenMsgs as Message[]) {
const msg = backendMessageToChatMessage(m, agentType, "Queen Bee");
msg.role = "queen";
preRestoredMsgs.push(msg);
}
} catch {
// Not available — will start fresh
}
}
// Suppress the queen's intro cycle whenever we are about to restore a
// previous conversation, or whenever we have a stored session ID.
const willRestore = !!(restoreFrom);
if (willRestore || preRestoredMsgs.length > 0) suppressIntroRef.current.add(agentType);
// Pass coldRestoreId as queenResumeFrom so the backend writes queen
// messages into the ORIGINAL session's directory.
liveSession = await sessionsApi.create(undefined, undefined, undefined, prompt, coldRestoreId ?? undefined);
if (preRestoredMsgs.length > 0) {
preRestoredMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
if (activeId) {
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s =>
s.id === activeId ? { ...s, messages: preRestoredMsgs, graphNodes: [] } : s,
),
}));
}
restoredMessageCount = preRestoredMsgs.length;
} else if (restoreFrom && activeId) {
// We had a stored session but no messages on disk — wipe stale localStorage cache
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s =>
@@ -551,10 +674,8 @@ export default function Workspace() {
}));
}
liveSession = await sessionsApi.create(undefined, undefined, undefined, prompt);
// Show the initial prompt as a user message in chat (only on fresh create)
if (prompt && activeId) {
// Show the initial prompt as a user message only on a truly fresh session
if (prompt && restoredMessageCount === 0 && activeId) {
const userMsg: ChatMessage = {
id: makeId(), agent: "You", agentColor: "",
content: prompt, timestamp: "", type: "user", thread: agentType, createdAt: Date.now(),
@@ -568,16 +689,25 @@ export default function Workspace() {
}
}
// Store backendSessionId on the active Session object for persistence
// Store backendSessionId on the Session object for persistence.
// Also set historySourceId so the sidebar "already-open" check works
// even after cold-revive changes backendSessionId to a new live session ID.
if (activeId) {
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s =>
s.id === activeId ? { ...s, backendSessionId: liveSession!.session_id } : s,
s.id === activeId ? {
...s,
backendSessionId: liveSession!.session_id,
historySourceId: s.historySourceId || coldRestoreId || undefined,
} : s,
),
}));
}
// If no messages were actually restored, lift the intro suppression
if (restoredMessageCount === 0) suppressIntroRef.current.delete(agentType);
updateAgentState(agentType, {
sessionId: liveSession.session_id,
displayName: "Queen Bee",
@@ -601,22 +731,42 @@ export default function Workspace() {
try {
let liveSession: LiveSession | undefined;
let isResumedSession = false;
// Set when the stored session is cold (server restarted) so we can restore
// messages from the old session files after creating a new live session.
let coldRestoreId: string | undefined;
// Try to reconnect to an existing backend session (e.g., after browser refresh).
// The backendSessionId is persisted in localStorage per tab.
const storedSessionId = sessionsRef.current[agentType]?.[0]?.backendSessionId;
// Also check historySourceId — handleHistoryOpen populates this with the
// original session ID from the sidebar. Use it as a fallback for stored ID.
const historySourceId = sessionsRef.current[agentType]?.[0]?.historySourceId;
const storedSessionId = sessionsRef.current[agentType]?.[0]?.backendSessionId
|| historySourceId;
if (storedSessionId) {
try {
liveSession = await sessionsApi.get(storedSessionId);
isResumedSession = true;
const sessionData = await sessionsApi.get(storedSessionId);
if (sessionData.cold) {
// Server restarted — conversation files survive on disk, no live runtime.
coldRestoreId = storedSessionId;
} else {
liveSession = sessionData;
isResumedSession = true;
}
} catch {
// Session gone (server restarted, etc.) — fall through to create new
// 404: session was explicitly stopped (via closeAgentTab) but conversation
// files likely still exist on disk. Treat it as cold so we can restore.
// Verify files exist before assuming cold — if queenMessages succeeds with
// content, files are there.
coldRestoreId = historySourceId || storedSessionId;
}
}
if (!liveSession) {
// Reconnect failed — clear stale cached messages from localStorage restore
if (storedSessionId) {
// Reconnect failed — clear stale cached messages from localStorage restore.
// NEVER wipe when: (a) doing a cold restore (we'll restore from disk) or
// (b) handleHistoryOpen already pre-populated messages (alreadyHasMessages).
const alreadyHasMessages = (sessionsRef.current[agentType] || [])[0]?.messages?.length > 0;
if (storedSessionId && !coldRestoreId && !alreadyHasMessages) {
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
@@ -625,8 +775,48 @@ export default function Workspace() {
}));
}
// CRITICAL: Pre-fetch queen messages from the old session directory BEFORE
// creating the new session. When queen_resume_from is set the new session writes
// to the SAME directory, so if we fetch after creation we risk capturing the
// new queen's greeting in the restored history.
// SKIP if messages were already pre-populated by handleHistoryOpen (avoids
// double-fetch and greeting leakage).
let preQueenMsgs: ChatMessage[] = [];
if (coldRestoreId && !alreadyHasMessages) {
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(coldRestoreId);
// Also pre-fetch worker messages from the old session if a resumable worker exists
const displayNameTemp = formatAgentDisplayName(agentPath);
for (const m of queenMsgs as Message[]) {
const msg = backendMessageToChatMessage(m, agentType, "Queen Bee");
msg.role = "queen";
preQueenMsgs.push(msg);
}
// Also try to grab worker messages while we're here
try {
const { sessions: workerSessions } = await sessionsApi.workerSessions(coldRestoreId);
const resumable = workerSessions.find(s => s.status === "active" || s.status === "paused");
if (resumable) {
const { messages: wMsgs } = await sessionsApi.messages(coldRestoreId, resumable.session_id);
for (const m of wMsgs as Message[]) {
preQueenMsgs.push(backendMessageToChatMessage(m, agentType, displayNameTemp));
}
}
} catch { /* not critical */ }
} catch {
// Not available — will start fresh
}
}
// Suppress intro whenever we are about to restore a previous conversation.
// The user never expects a greeting when reopening a session.
if (coldRestoreId) suppressIntroRef.current.add(agentType);
try {
liveSession = await sessionsApi.create(agentType);
// Pass coldRestoreId as queenResumeFrom so the backend writes queen
// messages into the ORIGINAL session's directory — all conversation
// history accumulates in one place across server restarts.
liveSession = await sessionsApi.create(agentPath, undefined, undefined, undefined, coldRestoreId ?? undefined);
} catch (loadErr: unknown) {
// 424 = credentials required — open the credentials modal
if (loadErr instanceof ApiError && loadErr.status === 424) {
@@ -671,6 +861,18 @@ export default function Workspace() {
liveSession = body as unknown as LiveSession;
}
}
// If we pre-fetched messages for a cold restore, populate the UI immediately.
// This happens before the SSE connection opens so no greeting can slip through.
if (preQueenMsgs.length > 0) {
preQueenMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: preQueenMsgs, graphNodes: [] } : s,
),
}));
}
}
// At this point liveSession is guaranteed set — if both reconnect and create
@@ -685,43 +887,58 @@ export default function Workspace() {
queenBuilding: initialMode === "building",
});
// Update the session label
// Update the session label + backendSessionId. Also set historySourceId
// so the sidebar "already-open" check works even after cold-revive changes
// backendSessionId to a new live session ID.
setSessionsByAgent((prev) => {
const sessions = prev[agentType] || [];
if (!sessions.length) return prev;
return {
...prev,
[agentType]: sessions.map((s, i) =>
i === 0 ? { ...s, label: sessions.length === 1 ? displayName : `${displayName} #${i + 1}`, backendSessionId: session.session_id } : s,
i === 0 ? {
...s,
// Preserve existing label if it was already set with a #N suffix by
// addAgentSession/handleHistoryOpen. Only overwrite with the bare
// displayName when the label doesn't match the resolved display name.
label: s.label.startsWith(displayName) ? s.label : displayName,
backendSessionId: session.session_id,
// Preserve existing historySourceId; set it from coldRestoreId if missing
historySourceId: s.historySourceId || coldRestoreId || undefined,
} : s,
),
};
});
// Check worker session status (detects running worker).
// Only restore messages when rejoining an existing backend session.
// Restore messages when rejoining an existing session OR cold-restoring from disk.
let isWorkerRunning = false;
const restoredMsgs: ChatMessage[] = [];
try {
const { sessions: workerSessions } = await sessionsApi.workerSessions(session.session_id);
const resumable = workerSessions.find(
(s) => s.status === "active" || s.status === "paused",
);
isWorkerRunning = resumable?.status === "active";
// For cold-restore, use the old session ID. For live resume, use current session.
const historyId = coldRestoreId ?? (isResumedSession ? session.session_id : undefined);
if (isResumedSession && resumable) {
const { messages } = await sessionsApi.messages(session.session_id, resumable.session_id);
for (const m of messages as Message[]) {
restoredMsgs.push(backendMessageToChatMessage(m, agentType, displayName));
}
}
} catch {
// Worker session listing failed — not critical
}
// Restore queen conversation when rejoining an existing session
if (isResumedSession) {
// For LIVE resume (not cold restore), fetch worker + queen messages now.
// For cold restore they were already pre-fetched above (before create) so we skip to avoid
// double-restoring and to avoid capturing the new greeting.
if (historyId && !coldRestoreId) {
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(session.session_id);
const { sessions: workerSessions } = await sessionsApi.workerSessions(historyId);
const resumable = workerSessions.find(
(s) => s.status === "active" || s.status === "paused",
);
isWorkerRunning = resumable?.status === "active";
if (resumable) {
const { messages } = await sessionsApi.messages(historyId, resumable.session_id);
for (const m of messages as Message[]) {
restoredMsgs.push(backendMessageToChatMessage(m, agentType, displayName));
}
}
} catch {
// Worker session listing failed — not critical
}
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(historyId);
for (const m of queenMsgs as Message[]) {
const msg = backendMessageToChatMessage(m, agentType, "Queen Bee");
msg.role = "queen";
@@ -732,7 +949,8 @@ export default function Workspace() {
}
}
// Merge queen + worker messages in chronological order
// Merge messages in chronological order (only for live resume; cold restore
// was already applied above before create).
if (restoredMsgs.length > 0) {
restoredMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setSessionsByAgent((prev) => ({
@@ -743,7 +961,12 @@ export default function Workspace() {
}));
}
// If no messages were actually restored, lift the intro suppression gate
if (restoredMsgs.length === 0 && !coldRestoreId) suppressIntroRef.current.delete(agentType);
updateAgentState(agentType, {
sessionId: session.session_id,
displayName,
ready: true,
loading: false,
queenReady: true,
@@ -1017,6 +1240,9 @@ export default function Workspace() {
const isQueen = streamId === "queen";
if (isQueen) console.log('[QUEEN] handleSSEEvent:', event.type, 'agentType:', agentType);
// Drop queen message content while suppressing the auto-intro after a cold-restore.
// Uses a synchronous ref to avoid race conditions with React state batching.
const suppressQueenMessages = isQueen && suppressIntroRef.current.has(agentType);
const agentDisplayName = agentStates[agentType]?.displayName;
const displayName = isQueen ? "Queen Bee" : (agentDisplayName || undefined);
const role = isQueen ? "queen" as const : "worker" as const;
@@ -1068,6 +1294,7 @@ export default function Workspace() {
case "execution_completed":
if (isQueen) {
suppressIntroRef.current.delete(agentType);
updateAgentState(agentType, { isTyping: false, queenIsTyping: false });
} else {
// Flush any remaining LLM snapshots before clearing state
@@ -1106,7 +1333,7 @@ export default function Workspace() {
case "llm_text_delta": {
const chatMsg = sseEventToChatMessage(event, agentType, displayName, currentTurn);
if (isQueen) console.log('[QUEEN] chatMsg:', chatMsg?.id, chatMsg?.content?.slice(0, 50), 'turn:', currentTurn);
if (chatMsg) {
if (chatMsg && !suppressQueenMessages) {
if (isQueen) chatMsg.role = role;
upsertChatMessage(agentType, chatMsg);
}
@@ -1149,26 +1376,30 @@ export default function Workspace() {
const cur = prev[agentType] || defaultAgentState();
const workerQuestionActive = cur.pendingQuestionSource === "worker";
if (isAutoBlock && workerQuestionActive) {
return { ...prev, [agentType]: {
return {
...prev, [agentType]: {
...cur,
awaitingInput: true,
isTyping: false,
isStreaming: false,
queenIsTyping: false,
queenBuilding: false,
}
};
}
return {
...prev, [agentType]: {
...cur,
awaitingInput: true,
isTyping: false,
isStreaming: false,
queenIsTyping: false,
queenBuilding: false,
}};
}
return { ...prev, [agentType]: {
...cur,
awaitingInput: true,
isTyping: false,
isStreaming: false,
queenIsTyping: false,
queenBuilding: false,
pendingQuestion: prompt || null,
pendingOptions: options,
pendingQuestionSource: "queen",
}};
pendingQuestion: prompt || null,
pendingOptions: options,
pendingQuestionSource: "queen",
}
};
});
} else {
// Worker input request.
@@ -1200,7 +1431,7 @@ export default function Workspace() {
queenIsTyping: false,
pendingQuestion: prompt || null,
pendingOptions: options,
pendingQuestionSource: options ? "worker" : null,
pendingQuestionSource: "worker",
});
}
}
@@ -1547,13 +1778,13 @@ export default function Workspace() {
case "worker_loaded": {
const workerName = event.data?.worker_name as string | undefined;
const agentPathFromEvent = event.data?.agent_path as string | undefined;
const displayName = formatAgentDisplayName(workerName || agentType);
const displayName = formatAgentDisplayName(workerName || baseAgentType(agentType));
// Invalidate cached credential requirements so the modal fetches
// fresh data the next time it opens (the new agent may have
// different credential needs than the previous one).
clearCredentialCache(agentPathFromEvent);
clearCredentialCache(agentType);
clearCredentialCache(baseAgentType(agentType));
// Update agent state: new display name, reset graph so topology refetch triggers
updateAgentState(agentType, {
@@ -1564,15 +1795,17 @@ export default function Workspace() {
nodeSpecs: [],
});
// Update session label (tab name) and clear graph nodes for fresh fetch
// Update ONLY the active session's label + graph nodes — never touch
// sessions belonging to a different tab sharing the same agentType key.
// Also clear worker messages so the fresh worker starts with a clean slate.
const activeId = activeSessionRef.current[agentType];
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s => ({
...s,
label: displayName,
graphNodes: [],
messages: s.messages.filter(m => m.role !== "worker"),
})),
[agentType]: (prev[agentType] || []).map(s =>
s.id === activeId || (!activeId && prev[agentType]?.[0]?.id === s.id)
? { ...s, label: displayName, graphNodes: [], messages: s.messages.filter(m => m.role !== "worker") }
: s
),
}));
// Explicitly fetch graph topology for the newly loaded worker
@@ -1610,7 +1843,7 @@ export default function Workspace() {
const activeSession = currentSessions.find(s => s.id === activeSessionId) || currentSessions[0];
const currentGraph = activeSession
? { nodes: activeSession.graphNodes, title: activeAgentState?.displayName || formatAgentDisplayName(activeWorker) }
? { nodes: activeSession.graphNodes, title: activeAgentState?.displayName || formatAgentDisplayName(baseAgentType(activeWorker)) }
: { nodes: [] as GraphNode[], title: "" };
// Build a flat list of all agent-type tabs for the tab bar
@@ -1652,6 +1885,41 @@ export default function Workspace() {
return;
}
// If worker is awaiting free-text input (no options / no QuestionWidget),
// route the message directly to the worker instead of the queen.
if (agentStates[activeWorker]?.awaitingInput && agentStates[activeWorker]?.pendingQuestionSource === "worker" && !agentStates[activeWorker]?.pendingOptions) {
const state = agentStates[activeWorker];
if (state?.sessionId && state?.ready) {
const userMsg: ChatMessage = {
id: makeId(), agent: "You", agentColor: "",
content: text, timestamp: "", type: "user", thread, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, userMsg] } : s
),
}));
updateAgentState(activeWorker, { awaitingInput: false, workerInputMessageId: null, isTyping: true, pendingQuestion: null, pendingOptions: null, pendingQuestionSource: null });
executionApi.workerInput(state.sessionId, text).catch((err: unknown) => {
const errMsg = err instanceof Error ? err.message : String(err);
const errorChatMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: `Failed to send to worker: ${errMsg}`,
timestamp: "", type: "system", thread, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, errorChatMsg] } : s
),
}));
updateAgentState(activeWorker, { isTyping: false, isStreaming: false });
});
}
return;
}
// If queen has a pending question widget, dismiss it when user types directly
if (agentStates[activeWorker]?.pendingQuestionSource === "queen") {
updateAgentState(activeWorker, { pendingQuestion: null, pendingOptions: null, pendingQuestionSource: null });
@@ -1667,6 +1935,7 @@ export default function Workspace() {
s.id === activeSession.id ? { ...s, messages: [...s.messages, userMsg] } : s
),
}));
suppressIntroRef.current.delete(activeWorker);
updateAgentState(activeWorker, { isTyping: true, queenIsTyping: true });
if (state?.sessionId && state?.ready) {
@@ -1786,7 +2055,7 @@ export default function Workspace() {
// Queue context for queen (fire-and-forget, no LLM response triggered)
if (question && state?.sessionId && state?.ready) {
const notification = `[Worker asked: "${question}" | User selected: "${answer}"]`;
executionApi.queenContext(state.sessionId, notification).catch(() => {});
executionApi.queenContext(state.sessionId, notification).catch(() => { });
}
}
}, [activeWorker, activeSession, agentStates, handleWorkerReply, handleSend, updateAgentState, setSessionsByAgent]);
@@ -1817,9 +2086,9 @@ export default function Workspace() {
// Unblock the waiting node with a dismiss signal
const dismissMsg = `[User dismissed the question: "${question}"]`;
if (source === "worker") {
executionApi.workerInput(state.sessionId, dismissMsg).catch(() => {});
executionApi.workerInput(state.sessionId, dismissMsg).catch(() => { });
} else {
executionApi.chat(state.sessionId, dismissMsg).catch(() => {});
executionApi.chat(state.sessionId, dismissMsg).catch(() => { });
}
}, [agentStates, activeWorker, updateAgentState]);
@@ -1867,9 +2136,9 @@ export default function Workspace() {
: Promise.resolve();
pausePromise
.catch(() => {}) // pause failure shouldn't block kill
.catch(() => { }) // pause failure shouldn't block kill
.then(() => sessionsApi.stop(state.sessionId!))
.catch(() => {}); // fire-and-forget
.catch(() => { }); // fire-and-forget
}
const allTypes = Object.keys(sessionsByAgent).filter(k => (sessionsByAgent[k] || []).length > 0);
@@ -1901,22 +2170,128 @@ export default function Workspace() {
// Create a new session for any agent type (used by NewTabPopover)
const addAgentSession = useCallback((agentType: string, agentLabel?: string) => {
const sessions = sessionsByAgent[agentType] || [];
const newIndex = sessions.length + 1;
const existingCreds = sessions.length > 0 ? sessions[0].credentials : undefined;
// Count all existing open tabs for this base agent type (first tab uses agentType
// as key; subsequent tabs use "agentType::frontendSessionId" as unique keys).
const existingTabCount = Object.keys(sessionsByAgent).filter(
k => baseAgentType(k) === agentType && (sessionsByAgent[k] || []).length > 0,
).length;
const newIndex = existingTabCount + 1;
const existingCreds = sessionsByAgent[agentType]?.[0]?.credentials;
const displayLabel = agentLabel || formatAgentDisplayName(agentType);
const label = newIndex === 1 ? displayLabel : `${displayLabel} #${newIndex}`;
const newSession = createSession(agentType, label, existingCreds);
// First tab keeps agentType as its key (backward-compatible with all existing
// logic). Additional tabs get a unique key so each has its own isolated
// agentStates slot, its own backend session, and its own tab-bar entry.
const tabKey = existingTabCount === 0 ? agentType : `${agentType}::${newSession.id}`;
if (tabKey !== agentType) {
newSession.tabKey = tabKey;
}
setSessionsByAgent(prev => ({
...prev,
[agentType]: [...(prev[agentType] || []), newSession],
[tabKey]: [newSession],
}));
setActiveSessionByAgent(prev => ({ ...prev, [agentType]: newSession.id }));
setActiveWorker(agentType);
setActiveSessionByAgent(prev => ({ ...prev, [tabKey]: newSession.id }));
setActiveWorker(tabKey);
}, [sessionsByAgent]);
const activeWorkerLabel = activeAgentState?.displayName || formatAgentDisplayName(activeWorker);
// Open a history session: switch to its existing tab, or open a new tab.
// Async so we can pre-fetch messages before creating the tab — this gives
// instant visual feedback without waiting for loadAgentForType.
const handleHistoryOpen = useCallback(async (sessionId: string, agentPath?: string | null, agentName?: string | null) => {
// Already open as a tab — just switch to it.
for (const [type, sessions] of Object.entries(sessionsByAgent)) {
for (const s of sessions) {
if (s.backendSessionId === sessionId || s.historySourceId === sessionId) {
setActiveWorker(type);
setActiveSessionByAgent(prev => ({ ...prev, [type]: s.id }));
if (s.messages.length > 0) {
suppressIntroRef.current.add(type);
}
return;
}
}
}
// Pre-fetch messages from disk so the tab opens with conversation already shown.
// This happens BEFORE creating the tab so no "new session" empty state is visible.
let prefetchedMessages: ChatMessage[] = [];
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(sessionId);
for (const m of queenMsgs as Message[]) {
const resolvedType = agentPath || "new-agent";
const msg = backendMessageToChatMessage(m, resolvedType, "Queen Bee");
msg.role = "queen";
prefetchedMessages.push(msg);
}
if (prefetchedMessages.length > 0) {
prefetchedMessages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
}
} catch {
// Not available — session will open empty and loadAgentForType will try again
}
const resolvedAgentType = agentPath || "new-agent";
const existingTabCount = Object.keys(sessionsByAgent).filter(
k => baseAgentType(k) === resolvedAgentType && (sessionsByAgent[k] || []).length > 0
).length;
const rawLabel = agentName ||
(agentPath ? agentPath.replace(/\/$/, "").split("/").pop()?.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) || agentPath : null) ||
"New Agent";
const label = existingTabCount === 0 ? rawLabel : `${rawLabel} #${existingTabCount + 1}`;
const newSession = createSession(resolvedAgentType, label);
newSession.backendSessionId = sessionId;
newSession.historySourceId = sessionId;
// Pre-populate messages so the chat panel immediately shows the conversation
if (prefetchedMessages.length > 0) {
newSession.messages = prefetchedMessages;
}
const tabKey = existingTabCount === 0 ? resolvedAgentType : `${resolvedAgentType}::${newSession.id}`;
if (tabKey !== resolvedAgentType) newSession.tabKey = tabKey;
// Suppress queen intro BEFORE the tab is created so loadAgentForType
// never sees an unsuppressed window — the user never expects a greeting on reopen.
if (prefetchedMessages.length > 0 || sessionId) {
suppressIntroRef.current.add(tabKey);
}
setSessionsByAgent(prev => ({ ...prev, [tabKey]: [newSession] }));
setActiveSessionByAgent(prev => ({ ...prev, [tabKey]: newSession.id }));
setActiveWorker(tabKey);
}, [sessionsByAgent]);
// Post-mount: open the session from the URL ?session= param via handleHistoryOpen.
// This runs AFTER persisted tabs are hydrated, so dedup works correctly.
// Use a ref guard so it fires exactly once even in React StrictMode.
useEffect(() => {
if (mountedRef.current) return;
mountedRef.current = true;
const sid = initialSessionIdRef.current;
if (!sid) return;
// Fetch agent metadata from the backend so handleHistoryOpen gets the right
// agentPath and agentName (needed to label the tab correctly).
sessionsApi.history().then(r => {
const match = r.sessions.find((s: { session_id: string }) => s.session_id === sid);
handleHistoryOpen(
sid,
match?.agent_path ?? initialAgentRef.current !== "new-agent" ? initialAgentRef.current : null,
match?.agent_name ?? null,
);
}).catch(() => {
// History fetch failed — still open the session with what we know.
handleHistoryOpen(
sid,
initialAgentRef.current !== "new-agent" ? initialAgentRef.current : null,
null,
);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const activeWorkerLabel = activeAgentState?.displayName || formatAgentDisplayName(baseAgentType(activeWorker));
return (
@@ -1965,9 +2340,11 @@ export default function Workspace() {
{/* Main content area */}
<div className="flex flex-1 min-h-0">
<div className="w-[340px] min-w-[280px] bg-card/30 flex flex-col border-r border-border/30">
{/* ── Pipeline graph + chat ──────────────────────────────────── */}
<div className="w-[300px] min-w-[240px] bg-card/30 flex flex-col border-r border-border/30">
<div className="flex-1 min-h-0">
<AgentGraph
<AgentGraph
nodes={currentGraph.nodes}
title={currentGraph.title}
onNodeClick={(node) => setSelectedNode(prev => prev?.id === node.id ? null : node)}
@@ -2065,7 +2442,7 @@ export default function Workspace() {
<div className="flex items-start gap-3 min-w-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 bg-[hsl(210,40%,55%)]/15 border border-[hsl(210,40%,55%)]/25">
<span className="text-sm" style={{ color: "hsl(210,40%,55%)" }}>
{{"webhook": "\u26A1", "timer": "\u23F1", "api": "\u2192", "event": "\u223F"}[selectedNode.triggerType || ""] || "\u26A1"}
{{ "webhook": "\u26A1", "timer": "\u23F1", "api": "\u2192", "event": "\u223F" }[selectedNode.triggerType || ""] || "\u26A1"}
</span>
</div>
<div className="min-w-0">
+10 -3
View File
@@ -53,7 +53,13 @@ def log_error(message: str):
def run_command(cmd: list, error_msg: str) -> bool:
"""Run a command and return success status."""
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return True
except subprocess.CalledProcessError as e:
log_error(error_msg)
@@ -97,7 +103,7 @@ def main():
if mcp_config_path.exists():
log_success("MCP configuration found at .mcp.json")
logger.info("Configuration:")
with open(mcp_config_path) as f:
with open(mcp_config_path, encoding="utf-8") as f:
config = json.load(f)
logger.info(json.dumps(config, indent=2))
else:
@@ -114,7 +120,7 @@ def main():
}
}
with open(mcp_config_path, "w") as f:
with open(mcp_config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
log_success("Created .mcp.json")
@@ -129,6 +135,7 @@ def main():
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
log_success("MCP server module verified")
except subprocess.CalledProcessError as e:
+5
View File
@@ -68,6 +68,7 @@ class TestFrameworkModule:
[sys.executable, "-m", "framework", "--help"],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(project_root / "core"),
)
assert result.returncode == 0
@@ -79,6 +80,7 @@ class TestFrameworkModule:
[sys.executable, "-m", "framework", "list", "--help"],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(project_root / "core"),
)
assert result.returncode == 0
@@ -104,6 +106,7 @@ class TestHiveEntryPoint:
["hive", "--help"],
capture_output=True,
text=True,
encoding="utf-8",
)
assert result.returncode == 0
assert "run" in result.stdout.lower()
@@ -115,6 +118,7 @@ class TestHiveEntryPoint:
["hive", "list", "--help"],
capture_output=True,
text=True,
encoding="utf-8",
)
assert result.returncode == 0
@@ -124,5 +128,6 @@ class TestHiveEntryPoint:
["hive", "run", "nonexistent_agent_xyz"],
capture_output=True,
text=True,
encoding="utf-8",
)
assert result.returncode != 0
+23
View File
@@ -0,0 +1,23 @@
"""Tests for framework/config.py - Hive configuration loading."""
import logging
from framework.config import get_hive_config
class TestGetHiveConfig:
"""Test get_hive_config() logs warnings on parse errors."""
def test_logs_warning_on_malformed_json(self, tmp_path, monkeypatch, caplog):
"""Test that malformed JSON logs warning and returns empty dict."""
config_file = tmp_path / "configuration.json"
config_file.write_text('{"broken": }')
monkeypatch.setattr("framework.config.HIVE_CONFIG_FILE", config_file)
with caplog.at_level(logging.WARNING):
result = get_hive_config()
assert result == {}
assert "Failed to load Hive config" in caplog.text
assert str(config_file) in caplog.text
+2 -2
View File
@@ -232,7 +232,7 @@ async def test_shared_session_reuses_directory_and_memory(tmp_path):
# Verify primary session's state.json exists and has the primary entry_point
primary_state_path = tmp_path / "sessions" / primary_exec_id / "state.json"
assert primary_state_path.exists()
primary_state = json.loads(primary_state_path.read_text())
primary_state = json.loads(primary_state_path.read_text(encoding="utf-8"))
assert primary_state["entry_point"] == "primary"
# Async stream — simulates a webhook entry point sharing the session
@@ -275,7 +275,7 @@ async def test_shared_session_reuses_directory_and_memory(tmp_path):
# State.json should NOT have been overwritten by the async execution
# (it should still show the primary entry point)
final_state = json.loads(primary_state_path.read_text())
final_state = json.loads(primary_state_path.read_text(encoding="utf-8"))
assert final_state["entry_point"] == "primary"
# Verify only ONE session directory exists (not two)
+2 -2
View File
@@ -184,7 +184,7 @@ class TestPathTraversalWithActualFiles:
# Create a secret file outside storage
secret_file = tmpdir_path / "secret.txt"
secret_file.write_text("SENSITIVE_DATA")
secret_file.write_text("SENSITIVE_DATA", encoding="utf-8")
storage = FileStorage(storage_dir)
@@ -193,7 +193,7 @@ class TestPathTraversalWithActualFiles:
storage.get_runs_by_goal("../secret")
# Verify the secret file was not accessed (still contains original data)
assert secret_file.read_text() == "SENSITIVE_DATA"
assert secret_file.read_text(encoding="utf-8") == "SENSITIVE_DATA"
def test_cannot_write_outside_storage(self):
"""Verify that we can't write files outside storage directory."""
+5 -2
View File
@@ -353,7 +353,9 @@ class TestRuntimeLogger:
# Verify the file exists and has one line
jsonl_path = tmp_path / "logs" / "sessions" / run_id / "logs" / "tool_logs.jsonl"
assert jsonl_path.exists()
lines = [line for line in jsonl_path.read_text().strip().split("\n") if line]
lines = [
line for line in jsonl_path.read_text(encoding="utf-8").strip().split("\n") if line
]
assert len(lines) == 1
data = json.loads(lines[0])
@@ -376,7 +378,8 @@ class TestRuntimeLogger:
jsonl_path = tmp_path / "logs" / "sessions" / run_id / "logs" / "details.jsonl"
assert jsonl_path.exists()
lines = [line for line in jsonl_path.read_text().strip().split("\n") if line]
content = jsonl_path.read_text(encoding="utf-8").strip()
lines = [line for line in content.split("\n") if line]
assert len(lines) == 1
data = json.loads(lines[0])
+1 -1
View File
@@ -98,7 +98,7 @@ class TestFileStorageRunOperations:
assert run_file.exists()
# Verify it's valid JSON
with open(run_file) as f:
with open(run_file, encoding="utf-8") as f:
data = json.load(f)
assert data["id"] == "my_run"
+14 -3
View File
@@ -71,6 +71,7 @@ def main():
capture_output=True,
text=True,
check=True,
encoding="utf-8",
)
framework_path = result.stdout.strip()
success(f"installed at {framework_path}")
@@ -84,7 +85,12 @@ def main():
missing_deps = []
for dep in ["mcp", "fastmcp"]:
try:
subprocess.run([sys.executable, "-c", f"import {dep}"], capture_output=True, check=True)
subprocess.run(
[sys.executable, "-c", f"import {dep}"],
capture_output=True,
check=True,
encoding="utf-8",
)
except subprocess.CalledProcessError:
missing_deps.append(dep)
@@ -103,6 +109,7 @@ def main():
capture_output=True,
text=True,
check=True,
encoding="utf-8",
)
success("loads successfully")
except subprocess.CalledProcessError as e:
@@ -115,7 +122,7 @@ def main():
mcp_config = script_dir / ".mcp.json"
if mcp_config.exists():
try:
with open(mcp_config) as f:
with open(mcp_config, encoding="utf-8") as f:
config = json.load(f)
if "mcpServers" in config and "agent-builder" in config["mcpServers"]:
@@ -149,7 +156,10 @@ def main():
for module in modules_to_check:
try:
subprocess.run(
[sys.executable, "-c", f"import {module}"], capture_output=True, check=True
[sys.executable, "-c", f"import {module}"],
capture_output=True,
check=True,
encoding="utf-8",
)
except subprocess.CalledProcessError:
failed_modules.append(module)
@@ -174,6 +184,7 @@ def main():
text=True,
check=True,
timeout=5,
encoding="utf-8",
)
if "OK" in result.stdout:
success("server can start")
+145
View File
@@ -0,0 +1,145 @@
# Integration Bounty Program
Earn XP, Discord roles, and money by testing, documenting, and building integrations for the Aden agent framework.
## Why Contribute?
**Your name in the product.** When you promote a tool to verified, your GitHub handle goes in the tool's README under `Contributed by`. Every agent that uses that integration carries your name — permanent credit in a production codebase.
**Visible status.** Your Discord tier role is earned, not bought. When you answer a question in `#integrations-help` with a Core Contributor badge, people listen.
**Weekly races.** Every Monday the bot posts the leaderboard. Top 3 get medal emojis. The best work gets highlighted in announcements.
**The path to paid.** Core Contributor unlocks real money. It takes sustained quality work across testing, docs, and code — the scarcity makes it matter.
## How It Works
1. Pick a bounty from the [GitHub issues board](https://github.com/adenhq/hive/issues?q=is%3Aissue+is%3Aopen+label%3A%22bounty%3A*%22)
2. Claim it by commenting on the issue
3. Do the work and submit a PR (or test report)
4. A maintainer reviews and merges
5. You automatically get XP in Discord via Lurkr
6. At certain levels, you unlock roles. At the top tier, you unlock paid bounties.
## Tiers
| Tier | How to Reach | Rewards |
| --------------------------- | -------------------------- | ------------------------------------------------------------- |
| **Agent Builder** | ~500 XP (Lurkr level 5) | Discord role, bounty board access |
| **Open Source Contributor** | ~2,000 XP (Lurkr level 15) | Discord role, name in CONTRIBUTORS.md and tool READMEs |
| **Core Contributor** | Maintainer-approved | Monetary payout per bounty, private `#bounty-payouts` channel |
Lurkr auto-assigns the first two roles. Core Contributor requires sustained, quality contributions across multiple bounty types and a maintainer vouching for you.
## Bounty Types
| Type | Label | Points | What You Do |
| --------------------- | ----------------- | ------ | -------------------------------------------------------------------------- |
| **Test a tool** | `bounty:test` | 20 | Test with a real API key, submit a report with logs |
| **Write docs** | `bounty:docs` | 20 | Write a README following the [template](templates/tool-readme-template.md) |
| **Code contribution** | `bounty:code` | 30 | Add health checker, fix a bug, or improve an integration |
| **New integration** | `bounty:new-tool` | 75 | Build a complete integration from scratch |
Promoting a tool from unverified to verified is the final step — submit a PR moving it from `_register_unverified()` to `_register_verified()` after the [promotion checklist](promotion-checklist.md) is complete.
## Quality Gates
- **PRs** must be merged by a maintainer (not self-merged)
- **Test reports** must follow the [test report template](templates/agent-test-report-template.md) with logs or session ID
- **READMEs** must follow the [tool README template](templates/tool-readme-template.md)
- **Claim before you start** — comment on the issue, wait for assignment
- No self-review, no splitting one change across multiple PRs, no AI-only submissions without verification
## Labels
| Label | Color | Meaning |
| ------------------- | ------------------ | --------------------------------------- |
| `bounty:test` | `#1D76DB` (blue) | Test a tool with a real API key |
| `bounty:docs` | `#FBCA04` (yellow) | Write or improve documentation |
| `bounty:code` | `#D93F0B` (orange) | Health checker, bug fix, or improvement |
| `bounty:new-tool` | `#6F42C1` (purple) | Build a new integration from scratch |
| `difficulty:easy` | `#BFD4F2` | Good first contribution |
| `difficulty:medium` | `#D4C5F9` | Requires some familiarity |
| `difficulty:hard` | `#F9D0C4` | Significant effort or expertise needed |
## Discord
```
#integrations-announcements — Bounties, leaderboard, tool promotions (bot + admin only)
#integrations-help — Questions, testing coordination, showcases
#bounty-payouts — Dollar values and payout tracking (Core Contributors only)
```
## Leaderboard
Weekly leaderboard auto-posts to `#integrations-announcements` every Monday. Top 3 get medal emojis. Check your rank anytime with `/rank` in Discord.
XP comes from two sources: GitHub bounties (auto-pushed on PR merge) and Discord activity in `#integrations-help`.
## Launch Plan: The 55-Tool Blitz
A 2-week sprint to get all 55 unverified tools tested, documented, and health-checked.
### Day 1: Post Everything
- **41 `bounty:docs` issues** — tools missing READMEs, `difficulty:easy`, 20 pts each
- **40 `bounty:code` issues** — tools missing health checkers, `difficulty:medium`, 30 pts each
- **55 `bounty:test` issues** — one per unverified tool, `difficulty:medium`, 20 pts each
### Week 1-2
All bounty types open in parallel. Contributors self-select. Daily progress updates in `#integrations-announcements`. Day 14 wrap-up with final leaderboard and shoutouts.
## Automation
```
PR merged with bounty:* label
→ GitHub Action runs bounty-tracker.ts
→ Calculates points from label
→ Resolves GitHub → Discord ID via contributors.yml
→ Pushes XP to Lurkr API
→ Posts notification to #integrations-announcements
```
See the [Setup Guide](setup-guide.md) for full configuration (Lurkr, webhooks, secrets, labels).
### Identity Linking
Contributors link GitHub ↔ Discord by opening a [Link Discord Account](https://github.com/aden-hive/hive/issues/new?template=link-discord.yml) issue. A GitHub Action auto-adds them to `contributors.yml` and closes the issue.
Without this link, bounties are still tracked but Lurkr can't push XP to your Discord account.
### What Handles What
| Concern | Handled By | How |
| ------------------------ | -------------------------- | ----------------------------------------------- |
| Bounty point calculation | GitHub Actions | `bounty-completed.yml` reads PR labels |
| XP push to Discord | GitHub Actions → Lurkr API | `PATCH /levels/{guild}/users/{user}` |
| Discord engagement XP | Lurkr bot | Native message XP (configurable per-channel) |
| Leaderboard | Lurkr bot + GitHub Actions | `/leaderboard` in Discord + weekly webhook post |
| Agent Builder role | Lurkr bot | Auto-assigned at level 5 |
| OSS Contributor role | Lurkr bot | Auto-assigned at level 15 |
| Core Contributor role | Maintainer | Manual (involves money) |
| Identity linking | contributors.yml | PR-based, reviewed by maintainers |
## Guides
- **[Setup Guide](setup-guide.md)** — Admin setup from zero to running
- **[Game Master Manual](game-master-manual.md)** — Maintainer operations
- **[Contributor Guide](contributor-guide.md)** — Everything a contributor needs to start
## Reference
- [Promotion Checklist](promotion-checklist.md) — Criteria for unverified → verified
- [Tool README Template](templates/tool-readme-template.md)
- [Agent Test Report Template](templates/agent-test-report-template.md)
- [Building Tools Guide](../tools/BUILDING_TOOLS.md)
- [Lurkr API Docs](https://lurkr.gg/docs/api)
### Automation Files
- `.github/workflows/bounty-completed.yml` — PR merge → XP push + Discord notification
- `.github/workflows/weekly-leaderboard.yml` — Monday leaderboard post
- `scripts/bounty-tracker.ts` — Point calculation, Lurkr API, Discord formatting
- `scripts/setup-bounty-labels.sh` — One-time label setup
- `contributors.yml` — GitHub ↔ Discord identity mapping
+109
View File
@@ -0,0 +1,109 @@
# Contributor Guide — Integration Bounty Program
Earn XP, Discord roles, and eventually real money by testing and building integrations for the Aden agent framework.
## Getting Started
### 1. Link your GitHub and Discord
Open a [Link Discord Account](https://github.com/aden-hive/hive/issues/new?template=link-discord.yml) issue — just paste your Discord ID and submit. A GitHub Action will automatically add you to `contributors.yml` and close the issue.
To find your Discord ID: Discord Settings > Advanced > Enable **Developer Mode**, then right-click your name > **Copy User ID**.
Without this link, Lurkr can't push XP to your Discord account.
### 2. Pick your first bounty
Browse [GitHub Issues with bounty labels](https://github.com/adenhq/hive/issues?q=is%3Aissue+is%3Aopen+label%3A%22bounty%3A*%22). Start with `bounty:docs` or `difficulty:easy`.
Comment "I'd like to work on this" and wait for a maintainer to assign you.
## Tiers
| Tier | How to Reach | What You Get |
|------|-------------|--------------|
| **Agent Builder** | ~500 XP (Lurkr level 5) | Discord role, bounty board access |
| **Open Source Contributor** | ~2,000 XP (Lurkr level 15) | Discord role, name in CONTRIBUTORS.md and tool READMEs |
| **Core Contributor** | Maintainer nomination | Dollar values on bounties, paid per completion |
XP comes from GitHub bounties (auto-pushed on PR merge) and Discord activity in `#integrations-help`.
## Bounty Types
### Test a Tool (20 pts)
Test an unverified tool with a real API key and report what happens.
1. Get an API key for the service (the bounty issue links to where)
2. Run the tool functions with real data
3. Fill out the [test report template](templates/agent-test-report-template.md)
4. Submit as a comment on the issue or a file in a PR
Report both successes and failures. Finding bugs is valuable.
### Write Docs (20 pts)
Write a README for a tool that's missing one.
1. Read the tool's source code in `tools/src/aden_tools/tools/{tool_name}/`
2. Read the credential spec in `tools/src/aden_tools/credentials/`
3. Fill in the [tool README template](templates/tool-readme-template.md)
4. Submit a PR adding `README.md` to the tool directory
Function names and API URLs must match reality — no AI hallucinations.
### Code Contribution (30 pts)
Add a health checker, fix a bug, or improve an integration.
**Health checker:**
1. Find a lightweight API endpoint that validates the credential (GET, no writes)
2. Add `health_check_endpoint` to the tool's CredentialSpec
3. Implement a HealthChecker class in `tools/src/aden_tools/credentials/health_check.py`
4. Register in `HEALTH_CHECKERS`, run `uv run pytest tools/tests/test_credential_registry.py`
**Bug fix:**
1. Find a bug during testing, file an issue
2. Fix it in a PR with a test covering the bug
### New Integration (75 pts)
Build a complete integration from scratch.
1. Follow the [BUILDING_TOOLS.md](../tools/BUILDING_TOOLS.md) guide
2. Create: tool + credential spec + health checker + tests + README
3. Register in `_register_unverified()` in `tools/__init__.py`
4. Run `make check && make test`
Expect multiple review rounds.
## Rules
1. **Claim before you start** — comment on the issue, wait for assignment
2. **7-day window** — no PR within 7 days = bounty gets re-opened
3. **Max 3 active claims** — don't hoard bounties
4. **Quality matters** — PRs must pass CI and follow templates
5. **No self-review** and no AI-only submissions without verification
## FAQ
**Q: Do I need an API key for every tool I test?**
A: Yes. Most services have free tiers. The bounty issue links to where you get the key.
**Q: How do I become a Core Contributor?**
A: Contribute consistently across different bounty types for 4+ weeks. Maintainers will nominate you.
**Q: What if I haven't linked my Discord yet?**
A: You'll still get credit in GitHub, but no Lurkr XP or Discord roles. Add yourself to `contributors.yml`.
## Quick Reference
| What | Where |
|------|-------|
| Bounty board | [GitHub Issues](https://github.com/adenhq/hive/issues?q=is%3Aissue+is%3Aopen+label%3A%22bounty%3A*%22) |
| README template | [templates/tool-readme-template.md](templates/tool-readme-template.md) |
| Test report template | [templates/agent-test-report-template.md](templates/agent-test-report-template.md) |
| Promotion checklist | [promotion-checklist.md](promotion-checklist.md) |
| Building tools | [BUILDING_TOOLS.md](../tools/BUILDING_TOOLS.md) |
| Discord | [Join](https://discord.com/invite/MXE49hrKDk) |
| Your rank | `/rank` in Discord |
+107
View File
@@ -0,0 +1,107 @@
# Game Master Manual
Operations guide for maintainers running the Integration Bounty Program.
## Your Role
- Post bounty issues and set dollar values for Core Contributors
- Assign claimed bounties to contributors
- Review and merge bounty PRs (auto-triggers XP awards)
- Manage the Core Contributor role
- Monitor for gaming and low-quality submissions
## Handling Bounty Claims
When someone comments "I'd like to work on this":
1. For `difficulty:easy`, assign immediately
2. For `difficulty:medium`/`difficulty:hard`, check if they've done easier bounties first
3. Assign via GitHub. If no PR within 7 days, unassign and re-open
## Reviewing Bounty PRs
1. Verify the PR matches the bounty issue
2. Check quality gates (below)
3. A **different maintainer** must approve than the one who created the bounty
4. Apply the correct `bounty:*` label to the PR before merging
5. Merge — the GitHub Action auto-awards XP and posts to Discord
6. Close the linked bounty issue
### Quality Gates
**`bounty:docs`:**
- [ ] Follows the [tool README template](templates/tool-readme-template.md)
- [ ] Setup instructions are accurate (API key URL works)
- [ ] Function names match the actual code
- [ ] Not AI-generated without verification
**`bounty:test`:**
- [ ] Test report follows the [template](templates/agent-test-report-template.md)
- [ ] Includes logs, session ID, or screenshots
- [ ] Done with a real API key, not mocked
- [ ] Reports failures honestly
**`bounty:code`:**
- [ ] CI passes (`uv run pytest tools/tests/test_credential_registry.py` for health checks)
- [ ] Fix addresses root cause, not symptom
- [ ] New test added for bug fixes
**`bounty:new-tool`:**
- [ ] Full implementation: tool + credential spec + tests + README
- [ ] `make check && make test` passes
- [ ] Registered in `_register_unverified()` (not verified)
### Rejecting Submissions
1. Leave specific, constructive feedback
2. Request changes (don't close the PR)
3. 7 days to address. No response → close PR, unassign bounty
Never merge low-quality work just to be nice.
## Core Contributor Promotion
Core Contributor unlocks monetary rewards. The bar must be high.
**Promote when:**
- Active for **4+ weeks** with contributions across **3+ bounty types**
- PRs are consistently clean
- At least one maintainer vouches for them
**How:** Discuss with maintainers → assign role in Discord → announce in `#integrations-announcements` → add to `#bounty-payouts`
**Don't promote** if they only do easy bounties, have been active < 4 weeks, or show signs of gaming.
If a Core Contributor is inactive 8+ weeks, reach out privately first, then remove the role if no response.
## Dollar Values
Post dollar values in `#bounty-payouts` (Core Contributors only):
| Bounty Type | Dollar Range |
|-------------|-------------|
| `bounty:test` | $1030 |
| `bounty:docs` | $1020 |
| `bounty:code` | $2050 |
| `bounty:new-tool` | $50150 |
**Payout:** PR merged → verify quality → record in `#bounty-payouts` → process payment.
XP is always awarded regardless of budget. Money is a bonus layer.
## Anti-Gaming
| Pattern | Response |
|---------|----------|
| Splitting one change across multiple PRs | Reject extras, warn |
| AI-generated without verification | Reject, explain why |
| Claiming many bounties, completing few | Unassign after 7 days |
**First offense:** warning. **Second:** 2-week cooldown. **Third:** permanent removal.
## Keeping It Fresh
- Aim for 10+ unclaimed bounties at all times
- Unassign stale claims (>7 days)
- Shoutout exceptional contributions in announcements
- Post milestones ("10th tool promoted to verified!")
@@ -0,0 +1,99 @@
# Integration Promotion Checklist
Formal criteria for promoting a tool from **unverified** to **verified**. A tool must satisfy every required item before a maintainer moves it from `_register_unverified()` to `_register_verified()` in [tools/__init__.py](../tools/src/aden_tools/tools/__init__.py).
## Checklist
### Code Quality (Required)
- [ ] **`register_tools` function** follows the standard signature pattern from [BUILDING_TOOLS.md](../tools/BUILDING_TOOLS.md)
- [ ] **Error handling** — all tools return `{"error": ...}` dicts instead of raising exceptions
- [ ] **Credential handling** — graceful fallback when credentials are missing, with actionable `"help"` message
- [ ] **Input validation** — parameters are validated before making API calls
- [ ] **No hardcoded secrets** — API keys come from credentials adapter or environment variables only
### Credential Spec (Required)
- [ ] **CredentialSpec exists** in `tools/src/aden_tools/credentials/{category}.py`
- [ ] **`env_var`** is set and unique (no collisions with other specs)
- [ ] **`tools`** list includes every tool function name registered by this module
- [ ] **`help_url`** points to the page where users get their API key
- [ ] **`description`** is a clear one-liner
- [ ] **`credential_id`** and **`credential_key`** are set for credential store mapping
- [ ] **Spec is merged** into `CREDENTIAL_SPECS` in `credentials/__init__.py`
### Health Check (Required)
- [ ] **`health_check_endpoint`** is set in the CredentialSpec
- [ ] **HealthChecker class** is implemented in `tools/src/aden_tools/credentials/health_check.py`
- [ ] **Checker is registered** in the `HEALTH_CHECKERS` dict
- [ ] **Handles 200** (valid), **401** (invalid/expired), and **429** (rate limited but valid) responses
- [ ] **Registry tests pass**`uv run pytest tools/tests/test_credential_registry.py -v`
### Documentation (Required)
- [ ] **README.md** exists in the tool directory, following the [tool README template](templates/tool-readme-template.md)
- [ ] **Setup instructions** — how to get and configure the API key
- [ ] **Tool table** — lists all tool functions with descriptions
- [ ] **Usage examples** — at least one example per tool function
- [ ] **API reference link** — link to the service's API docs
### Testing (Required)
- [ ] **Unit tests exist** in `tools/tests/tools/test_{tool_name}.py`
- [ ] **Tests mock external APIs** — no live API calls in unit tests
- [ ] **Tests cover happy path** for each tool function
- [ ] **Tests cover error cases** — missing credentials, invalid input, API errors
- [ ] **CI passes**`make check && make test`
### Community Testing (Required)
- [ ] **At least 1 community member** has tested with a real API key
- [ ] **Agent test report submitted** following the [test report template](templates/agent-test-report-template.md)
- [ ] **Tool works in a real agent workflow** (not just isolated function calls)
- [ ] **No blocking issues** reported in the test report
### Optional (Bonus)
- [ ] Multiple community test reports from different testers
- [ ] Rate limit documentation
- [ ] Integration tests with sandboxed API accounts
- [ ] Pagination support for list endpoints
- [ ] Webhook support (if applicable to the service)
## Promotion Process
1. **Contributor opens a PR** that checks off all required items above
2. **PR description** includes links to: the tool README, the health checker, the test report(s)
3. **Maintainer reviews** the checklist — every required item must be verified
4. **Maintainer moves** the tool registration from `_register_unverified()` to `_register_verified()` in `tools/__init__.py`
5. **Maintainer adds the `bounty:code` label** to the PR — this triggers the GitHub Action to award XP via Lurkr and post a Discord notification
6. **Announcement** auto-posted in `#integrations-announcements` on Discord
## Current Status
### Tools Ready for Promotion Testing
The following 55 unverified tools have implementations, credential specs, and unit tests. They need documentation, health checks, and community testing to be promoted:
<details>
<summary>Full list of unverified tools</summary>
airtable, apify, asana, attio, aws_s3, azure_sql, calendly, cloudinary, confluence,
databricks, docker_hub, duckduckgo, gitlab, google_analytics, google_search_console,
google_sheets, greenhouse, huggingface, jira, kafka, langfuse, linear, lusha,
microsoft_graph, mongodb, n8n, notion, obsidian, pagerduty, pinecone, pipedrive,
plaid, powerbi, pushover, quickbooks, reddit, redis, redshift, salesforce, sap,
shopify, snowflake, supabase, terraform, tines, trello, twilio, twitter, vercel,
yahoo_finance, youtube, youtube_transcript, zendesk, zoho_crm, zoom
</details>
### Gap Summary
| Gap | Count | Bounty Type |
|-----|-------|-------------|
| Missing README | ~41 | `bounty:docs` |
| Missing health_check_endpoint | ~40 | `bounty:code` |
| Missing HealthChecker class | ~40 | `bounty:code` |
| No community test report | 55 | `bounty:test` |
+157
View File
@@ -0,0 +1,157 @@
# Integration Bounty Program — Setup Guide
Complete setup from zero to running. Estimated time: 30 minutes.
## Prerequisites
- Admin access to the GitHub repo
- Admin access to the Discord server
- `gh` CLI installed and authenticated
## Step 1: Create GitHub Labels (2 min)
```bash
./scripts/setup-bounty-labels.sh
```
This creates 7 labels: 4 bounty types (`bounty:test`, `bounty:docs`, `bounty:code`, `bounty:new-tool`) and 3 difficulty levels (`difficulty:easy`, `difficulty:medium`, `difficulty:hard`).
## Step 2: Create Discord Channels (3 min)
```
Category: Integrations
#integrations-announcements (read-only for non-admins)
#integrations-help
Category: Private
#bounty-payouts (visible only to Core Contributor role)
```
**Permissions:**
- `#integrations-announcements`: Everyone reads, only bots + admins post
- `#bounty-payouts`: Core Contributor role only
## Step 3: Create Discord Roles (2 min)
Order matters — higher = more prestigious:
| Role | Color | Hoisted | Mentionable |
| ----------------------- | ---------------- | ------- | ----------- |
| Core Contributor | Gold `#F1C40F` | Yes | Yes |
| Open Source Contributor | Purple `#9B59B6` | Yes | No |
| Agent Builder | Green `#2ECC71` | Yes | No |
## Step 4: Install and Configure Lurkr (10 min)
### 4a. Invite Lurkr
Go to https://lurkr.gg/ and invite the bot. Grant requested permissions.
### 4b. Enable Leveling
In Discord, run:
```
/config toggle option:Leveling System
```
### 4c. Configure XP and Cooldown (Dashboard)
Lurkr configures XP range and cooldown through the web dashboard, not slash commands.
1. Go to https://lurkr.gg/dashboard and select your server
2. Open the **Leveling** category
3. Set **XP range** to min 15, max 25
4. Set **Cooldown** to 60 seconds
### 4d. Configure Channel Settings
Set `#integrations-help` as a leveling channel with a 2x multiplier, and exclude announcement/payout channels:
1. In the Lurkr dashboard **Leveling** settings, add `#integrations-help` as a leveling channel
2. Set a **channel multiplier** of 2x for `#integrations-help` using `/config set` (channel multiplier option)
3. Do NOT add `#integrations-announcements` or `#bounty-payouts` as leveling channels
### 4e. Configure Role Rewards
Use `/config set` to add role rewards:
1. Set `@Agent Builder` as a role reward at **level 5**
2. Set `@Open Source Contributor` as a role reward at **level 15**
Do NOT auto-assign Core Contributor — that's maintainer-only.
### 4f. Generate Lurkr API Key
1. Go to https://lurkr.gg/ and log in
2. Profile > API settings > Create API Key
3. Select **Read/Write** (not read-only)
4. Copy the key
## Step 5: Create Discord Webhook (2 min)
1. Server Settings > Integrations > Webhooks > New Webhook
2. Name: `Bounty Tracker`, channel: `#integrations-announcements`
3. Copy the webhook URL
## Step 6: Add GitHub Secrets (3 min)
Repo Settings > Secrets and variables > Actions:
| Secret | Value |
| ---------------------------- | -------------------------- |
| `DISCORD_BOUNTY_WEBHOOK_URL` | Webhook URL from Step 5 |
| `LURKR_API_KEY` | Lurkr API key from Step 4f |
| `LURKR_GUILD_ID` | Your Discord server ID\* |
\*Enable Developer Mode in Discord, right-click server name > Copy Server ID.
## Step 7: Test the Pipeline (5 min)
```bash
GITHUB_TOKEN=$(gh auth token) \
GITHUB_REPOSITORY_OWNER=aden-hive \
GITHUB_REPOSITORY_NAME=hive \
bun run scripts/bounty-tracker.ts leaderboard
```
Then create a test PR with `bounty:docs` label, merge it, verify the Discord notification appears.
## Step 8: Seed the 55-Tool Blitz
Post all bounties at once on launch day:
**Documentation (41 issues):** `bounty:docs`, `difficulty:easy`, 20 pts
**Health checks (40 issues):** `bounty:code`, `difficulty:medium`, 30 pts
**Testing (55 issues):** `bounty:test`, `difficulty:medium`, 20 pts
### Tools missing READMEs
```
azure_sql, cloudinary, confluence, databricks, docker_hub, duckduckgo,
google_search_console, google_sheets, greenhouse, jira, kafka, lusha,
mongodb, notion, obsidian, pagerduty, pinecone, pipedrive, plaid,
pushover, quickbooks, redshift, sap, salesforce, shopify, snowflake,
supabase, terraform, tines, trello, twilio, twitter, vercel,
yahoo_finance, zoom, huggingface, langfuse, microsoft_graph, n8n,
powerbi, redis
```
## Verification Checklist
- [ ] Labels exist (`bounty:*` and `difficulty:*`)
- [ ] Discord channels and roles created
- [ ] Lurkr installed, leveling enabled, XP/cooldown configured in dashboard, role rewards set
- [ ] All 3 GitHub secrets added
- [ ] Both workflows enabled (`bounty-completed.yml`, `weekly-leaderboard.yml`)
- [ ] Test PR + merge triggers Discord notification
- [ ] `contributors.yml` exists at repo root
## Troubleshooting
**No Discord message:** Check `DISCORD_BOUNTY_WEBHOOK_URL` secret and Action logs.
**Lurkr XP not awarded:** Confirm API key is Read/Write, contributor is in `contributors.yml`, check Action logs for `Lurkr XP push failed`.
**Role not assigned:** Verify role rewards in the Lurkr dashboard or via `/config set`. Lurkr's role must be above the roles it assigns in server hierarchy.
@@ -0,0 +1,90 @@
# Agent Test Report: {tool_name}
<!-- Submit this report as a comment on the bounty issue, or as a file in a PR. -->
## Summary
- **Tool tested:** `{tool_name}`
- **Tester:** @{github_handle}
- **Date:** {YYYY-MM-DD}
- **Verdict:** Pass / Partial / Fail
## Environment
- **OS:** {e.g., macOS 15.2, Ubuntu 24.04}
- **Python:** {e.g., 3.12.1}
- **Hive version:** {commit hash or version}
- **API tier:** {e.g., Free, Pro — relevant for rate limits}
## Credential Setup
- **Auth method:** {API key / OAuth / Bearer token}
- **Health check result:** {Pass / Fail / No health checker available}
- **Setup difficulty:** {Easy / Medium / Hard}
- **Setup notes:** {Any friction, confusing docs, extra steps not documented}
## Agent Configuration
<!-- Describe the agent you built or used to test this tool. -->
```
Agent name: {name}
Tools used: {tool_name}, {any other tools}
Goal: {What the agent was supposed to accomplish}
```
## Test Results
### Tool Functions Tested
| Function | Input | Expected | Actual | Status |
|----------|-------|----------|--------|--------|
| `{function_name}` | {brief input description} | {expected behavior} | {what happened} | Pass/Fail |
| `{function_name}` | {brief input description} | {expected behavior} | {what happened} | Pass/Fail |
### Agent Workflow Test
<!-- Did the agent successfully use this tool to accomplish a task? -->
**Goal:** {What you asked the agent to do}
**Result:** {What actually happened}
**Session ID:** `{session_id if available}`
### Edge Cases Found
<!-- Document any unexpected behavior, errors, or limitations. -->
| Edge Case | Behavior | Severity |
|-----------|----------|----------|
| {e.g., empty query} | {what happened} | Low/Medium/High |
| {e.g., rate limit hit} | {what happened} | Low/Medium/High |
## Issues Found
<!-- List any bugs or problems. Link to new issues if you filed them. -->
- [ ] {Issue description} — {filed as #XXXX / not yet filed}
- [ ] {Issue description}
## Recommendations
<!-- Suggestions for the tool maintainer. -->
- {e.g., "Error message for missing API key should include the help URL"}
- {e.g., "Rate limit handling should retry with backoff"}
- {e.g., "Ready for promotion after health checker is added"}
## Evidence
<!-- Attach or link to logs, screenshots, or recordings. At minimum, include the session ID or key log output. -->
<details>
<summary>Logs</summary>
```
{Paste relevant log output here}
```
</details>
@@ -0,0 +1,71 @@
# {Tool Name} Tool
<!-- One-liner: what this tool does and what it enables agents to do. -->
{Brief description of what the tool does and its primary use case.}
## Setup
```bash
# Required
export {ENV_VAR}=your-api-key
```
**Get your key:**
1. Go to {help_url}
2. {Step to create/generate a key}
3. {Step to copy the key}
4. Set `{ENV_VAR}` environment variable
Alternatively, configure via the credential store (`CredentialStoreAdapter`).
<!-- If OAuth is supported, add: -->
<!-- **OAuth:** This integration also supports OAuth2 via Aden. -->
## Tools ({count})
| Tool | Description |
|------|-------------|
| `{tool_function_name}` | {What it does} |
| `{tool_function_name}` | {What it does} |
## Usage
### {Action name}
```python
result = {tool_function_name}(
param="value",
)
# Returns: {brief description of return value}
```
### {Action name}
```python
result = {tool_function_name}(
param="value",
)
# Returns: {brief description of return value}
```
## Scope
<!-- What this integration covers in its current form. -->
- {Capability 1}
- {Capability 2}
- {Capability 3}
## Rate Limits
<!-- Document known rate limits if applicable. Remove this section if not relevant. -->
| Tier | Limit |
|------|-------|
| Free | {X requests/minute} |
| Paid | {Y requests/minute} |
## API Reference
- [{Service} API Docs]({url})
+19 -2
View File
@@ -27,8 +27,22 @@ uv run python -c "import framework; import aden_tools; print('✓ Setup complete
## Building Your First Agent
Agents are not included by default in a fresh clone.
Agents are created using Claude Code or by manual creation in the
exports/ directory. Until an agent exists, agent validation and run
commands will fail.
### Option 1: Using Claude Code Skills (Recommended)
This is the recommended way to create your first agent.
**Requirements**
- Anthropic (Claude) API access
- Claude Code CLI installed
- Unix-based shell (macOS, Linux, or Windows via WSL)
```bash
# Setup already done via quickstart.sh above
@@ -120,7 +134,10 @@ hive/
## Running an Agent
```bash
# Browse and run agents interactively (Recommended)
# Launch the web dashboard in your browser
hive open
# Browse and run agents in terminal
hive tui
# Run a specific agent
@@ -164,7 +181,7 @@ PYTHONPATH=exports uv run python -m my_agent test --type success
## Next Steps
1. **TUI Dashboard**: Run `hive tui` to explore agents interactively
1. **Dashboard**: Run `hive open` to launch the web dashboard, or `hive tui` for the terminal UI
2. **Detailed Setup**: See [environment-setup.md](./environment-setup.md)
3. **Developer Guide**: See [developer-guide.md](./developer-guide.md)
4. **Build Agents**: Use `/hive` skill in Claude Code
+1 -3
View File
@@ -37,8 +37,6 @@ Ported from `agent_builder_server.py` lines 3484-3856. Pure filesystem reads —
| Tool | Purpose |
|------|---------|
| `list_agent_sessions(agent_name, status?, limit?)` | List sessions, filterable by status |
| `get_agent_session_state(agent_name, session_id)` | Full session state (memory excluded to prevent context bloat) |
| `get_agent_session_memory(agent_name, session_id, key?)` | Read memory contents from a session |
| `list_agent_checkpoints(agent_name, session_id)` | List checkpoints for debugging |
| `get_agent_checkpoint(agent_name, session_id, checkpoint_id?)` | Load a checkpoint's full state |
@@ -67,7 +65,7 @@ Add all 8 tools after the existing `undo_changes` tool:
# ── Meta-agent: Session & checkpoint inspection ───────────────
# _resolve_hive_agent_path(), _read_session_json(), _scan_agent_sessions(), _truncate_value()
# list_agent_sessions(), get_agent_session_state(), get_agent_session_memory()
# list_agent_sessions(), list_agent_checkpoints(), get_agent_checkpoint()
# list_agent_checkpoints(), get_agent_checkpoint()
# ── Meta-agent: Test execution ────────────────────────────────
+1 -1
View File
@@ -43,7 +43,7 @@ Dedicated tool server providing:
- **File I/O**: `read_file` (with line numbers, offset/limit), `write_file` (auto-mkdir), `edit_file` (9-strategy fuzzy matching ported from opencode), `list_directory`, `search_files` (regex)
- **Shell**: `run_command` (timeout, cwd, output truncation)
- **Git**: `undo_changes` (snapshot-based rollback)
- **Meta-agent**: `discover_mcp_tools`, `list_agents`, `list_agent_sessions`, `get_agent_session_state`, `get_agent_session_memory`, `list_agent_checkpoints`, `get_agent_checkpoint`, `run_agent_tests`
- **Meta-agent**: `discover_mcp_tools`, `list_agents`, `list_agent_sessions`, `list_agent_checkpoints`, `get_agent_checkpoint`, `run_agent_tests`
All file operations sandboxed to a configurable project root.
+1 -1
View File
@@ -16,7 +16,7 @@ The agent is deeply integrated with the framework: it can discover available MCP
- **`reference/`** — Framework guide, file templates, and anti-patterns docs embedded as agent reference material
### New: Coder Tools MCP Server (`tools/coder_tools_server.py`)
- 1500-line MCP server providing 15 tools: `read_file`, `write_file`, `edit_file` (with opencode-style 9-strategy fuzzy matching), `list_directory`, `search_files`, `run_command`, `undo_changes`, `discover_mcp_tools`, `list_agents`, `list_agent_sessions`, `get_agent_session_state`, `get_agent_session_memory`, `list_agent_checkpoints`, `get_agent_checkpoint`, `run_agent_tests`
- 1500-line MCP server providing 13 tools: `read_file`, `write_file`, `edit_file` (with opencode-style 9-strategy fuzzy matching), `list_directory`, `search_files`, `run_command`, `undo_changes`, `discover_mcp_tools`, `list_agents`, `list_agent_sessions`, `list_agent_checkpoints`, `get_agent_checkpoint`, `run_agent_tests`
- Path-scoped security: all file operations sandboxed to project root
- Git-based undo: automatic snapshots before writes with `undo_changes` rollback
+5 -5
View File
@@ -145,7 +145,7 @@ Implement the core execution engine where every Agent operates as an isolated, a
- [x] SharedState manager (runtime/shared_state.py)
- [x] Session-based storage (storage/session_store.py)
- [x] Isolation levels: ISOLATED, SHARED, SYNCHRONIZED
- [ ] **Default Monitoring Hooks**
- [x] **Default Monitoring Hooks**
- [ ] Performance metrics collection
- [ ] Resource usage tracking
- [ ] Health check endpoints
@@ -590,7 +590,7 @@ Write the Quick Start guide, detailed tool usage documentation, and set up the M
- [x] README with examples
- [x] Contributing guidelines
- [x] GitHub Page setup
- [ ] **Tool Usage Documentation**
- [x] **Tool Usage Documentation**
- [ ] Comprehensive tool documentation
- [ ] Tool integration examples
- [ ] Best practices guide
@@ -643,7 +643,7 @@ Expose basic REST/WebSocket endpoints for external control (Start, Stop, Pause,
- [x] Load/unload/start/restart in AgentRuntime
- [x] State persistence
- [x] Recovery mechanisms
- [ ] **REST API Endpoints**
- [x] **REST API Endpoints**
- [ ] Start endpoint for agent execution
- [ ] Stop endpoint for graceful shutdown
- [ ] Pause endpoint for execution suspension
@@ -661,7 +661,7 @@ Implement automated test execution, agent version control, and mandatory test-pa
- [x] Test framework with pytest integration (testing/)
- [x] Test result reporting
- [x] Test CLI commands (test-run, test-debug, etc.)
- [ ] **Automated Testing Pipeline**
- [x] **Automated Testing Pipeline**
- [ ] CI integration (GitHub Actions, etc.)
- [ ] Mandatory test-passing gates
- [ ] Coverage reporting
@@ -873,7 +873,7 @@ Build native frontend configurations to easily connect Open Hive's backend to lo
- [ ] Node.js runtime support
- [ ] Browser runtime support
- [ ] **Platform Compatibility**
- [ ] Windows support improvements
- [x] Windows support improvements
- [ ] macOS optimization
- [ ] Linux distribution support
+78
View File
@@ -0,0 +1,78 @@
# Tools
Hive agents interact with external services through **tools** — functions exposed via MCP (Model Context Protocol) servers. The main tool server lives at `tools/mcp_server.py` and registers integrations from the `aden_tools` package.
## Verified vs Unverified
Tools are split into two tiers:
| Tier | Description | Default |
|------|-------------|---------|
| **Verified** | Stable integrations tested on main. Always loaded. | On |
| **Unverified** | New or community integrations pending full review. | Off |
Verified tools include core capabilities like web search, GitHub, email, file system operations, and security scanners. Unverified tools cover newer integrations like Jira, Notion, Salesforce, Snowflake, and others that are functional but haven't completed the full review process.
## Enabling Unverified Tools
Set the `INCLUDE_UNVERIFIED_TOOLS` environment variable to opt in:
```bash
# Shell
INCLUDE_UNVERIFIED_TOOLS=true uv run python tools/mcp_server.py --stdio
```
### In `mcp_servers.json`
When configuring an agent's MCP server, pass the env var in the server config:
```json
{
"servers": [
{
"name": "tools",
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "tools/mcp_server.py", "--stdio"],
"env": {
"INCLUDE_UNVERIFIED_TOOLS": "true"
}
}
]
}
```
### In Docker
```bash
docker run -e INCLUDE_UNVERIFIED_TOOLS=true ...
```
### In Python
If calling `register_all_tools` directly (e.g., in a custom server):
```python
from aden_tools.tools import register_all_tools
register_all_tools(mcp, credentials=credentials, include_unverified=True)
```
Accepted values: `true`, `1`, `yes` (case-insensitive). Any other value or unset means off.
## Listing Available Tools
The MCP server logs registered tools at startup (HTTP mode):
```bash
uv run python tools/mcp_server.py
# [MCP] Registered 47 tools: [...]
```
In STDIO mode, logs go to stderr to keep stdout clean for JSON-RPC.
## Adding a New Tool
New tool integrations are added to `tools/src/aden_tools/tools/` and registered in `_register_unverified()` in `tools/src/aden_tools/tools/__init__.py`. Once reviewed and stabilized, they graduate to `_register_verified()`.
See the [developer guide](developer-guide.md) for the full contribution workflow.
-59
View File
@@ -1,59 +0,0 @@
# TUI Dashboard Guide
## Launching the TUI
There are two ways to launch the TUI dashboard:
```bash
# Browse and select an agent interactively
hive tui
# Launch the TUI for a specific agent
hive run exports/my_agent --tui
```
`hive tui` scans both `exports/` and `examples/templates/` for available agents, then presents a selection menu.
## Dashboard Panels
The TUI dashboard is divided into four areas:
- **Status Bar** - Shows the current agent name, execution state, and model in use
- **Graph Overview** - Live visualization of the agent's node graph with highlighted active node
- **Log Pane** - Scrollable event log streaming node transitions, LLM calls, and tool outputs
- **Chat REPL** - Input area for interacting with client-facing nodes (`ask_user()` prompts appear here)
## Keybindings
| Key | Action |
|---------------|-----------------------|
| `Tab` | Next panel |
| `Shift+Tab` | Previous panel |
| `Ctrl+S` | Save SVG screenshot |
| `Ctrl+O` | Command palette |
| `Q` | Quit |
## Panel Cycle Order
`Tab` cycles: **Log Pane → Graph View → Chat Input**
## Text Selection
Textual apps capture the mouse, so normal click-drag selection won't work by default. To select and copy text from any pane:
1. **Hold `Shift`** while clicking and dragging — this bypasses Textual's mouse capture and lets your terminal handle selection natively.
2. Copy with your terminal's shortcut (`Cmd+C` on macOS, `Ctrl+Shift+C` on most Linux terminals).
## Log Pane Scrolling
The log pane uses `auto_scroll=False`. New output only scrolls to the bottom when you are already at the bottom of the log. If you've scrolled up to read earlier output, it stays in place.
## Screenshots
`Ctrl+S` saves an SVG screenshot to the `screenshots/` directory with a timestamped filename. Open the SVG in any browser to view it.
## Tips
- Use `--mock` mode to explore agent execution without spending API credits: `hive run exports/my_agent --tui --mock`
- Override the default model with `--model`: `hive run exports/my_agent --model gpt-4o`
- Screenshots are saved as SVG files to `screenshots/` and can be opened in any browser
+1 -1
View File
@@ -191,7 +191,7 @@ Both events are handled in the cross-graph filter (events from non-active graphs
## Known Gaps
**Gap 1 — Resolved.** The queen is now the full `HiveCoderAgent` graph (not a minimal hand-assembled subset). `_load_judge_and_queen` calls `HiveCoderAgent._setup(mock_mode=True)` to load hive-tools MCP, then merges those tools into the worker runtime alongside monitoring tools. When the operator connects via Ctrl+Q, they get `coder_node` with `read_file`, `write_file`, `run_command`, `restart_agent`, `get_agent_session_state`, and all other hive-tools. The `ticket_triage_node` still handles auto-triage on ticket events. `self._queen_agent` is held on the TUI instance to keep the MCP process alive.
**Gap 1 — Resolved.** The queen is now the full `HiveCoderAgent` graph (not a minimal hand-assembled subset). `_load_judge_and_queen` calls `HiveCoderAgent._setup(mock_mode=True)` to load hive-tools MCP, then merges those tools into the worker runtime alongside monitoring tools. When the operator connects via Ctrl+Q, they get `coder_node` with `read_file`, `write_file`, `run_command`, `restart_agent`, and all other hive-tools. The `ticket_triage_node` still handles auto-triage on ticket events. `self._queen_agent` is held on the TUI instance to keep the MCP process alive.
**Gap 2 — LLM-hang detection latency.**
If the worker's LLM call hangs (API never returns), no new log entries are written. The judge detects this on its next timer tick (≤2 min). Bounded latency, not zero.
+1
View File
@@ -43,4 +43,5 @@ uv run python -m exports.my_research_agent --input '{"topic": "..."}'
| Template | Description |
|----------|-------------|
| [deep_research_agent](deep_research_agent/) | Interactive research agent that searches diverse sources, evaluates findings with user checkpoints, and produces a cited HTML report |
| [local_business_extractor](local_business_extractor/) | Finds local businesses on Google Maps, scrapes contact details, and syncs to Google Sheets |
| [tech_news_reporter](tech_news_reporter/) | Researches the latest technology and AI news from the web and produces a well-organized report |
@@ -0,0 +1,31 @@
# Local Business Extractor
Finds local businesses on Google Maps, scrapes their websites for contact details, and syncs everything to a Google Sheets spreadsheet.
## Nodes
| Node | Type | Description |
|------|------|-------------|
| `map-search-worker` | `gcu` (browser) | Searches Google Maps and extracts business names + website URLs |
| `extract-contacts` | `event_loop` | Scrapes business websites for emails, phone, hours, reviews, address |
| `sheets-sync` | `event_loop` | Appends extracted data to a Google Sheets spreadsheet |
## Flow
```
extract-contacts → sheets-sync → (loop back to extract-contacts)
map-search-worker (sub-agent)
```
## Tools used
- **Exa** — `exa_search`, `exa_get_contents` for web scraping
- **Google Sheets** — `google_sheets_create_spreadsheet`, `google_sheets_update_values`, `google_sheets_append_values`, `google_sheets_get_values`
- **Browser (GCU)** — automated Google Maps browsing
## Running
```bash
uv run python -m examples.templates.local_business_extractor run --query "bakeries in San Francisco"
```
@@ -0,0 +1,34 @@
"""Local Business Extractor package."""
from .agent import (
LocalBusinessExtractor,
default_agent,
goal,
nodes,
edges,
entry_node,
entry_points,
pause_nodes,
terminal_nodes,
conversation_mode,
identity_prompt,
loop_config,
)
from .config import default_config, metadata
__all__ = [
"LocalBusinessExtractor",
"default_agent",
"goal",
"nodes",
"edges",
"entry_node",
"entry_points",
"pause_nodes",
"terminal_nodes",
"conversation_mode",
"identity_prompt",
"loop_config",
"default_config",
"metadata",
]
@@ -0,0 +1,146 @@
"""
CLI entry point for Local Business Extractor.
"""
import asyncio
import json
import logging
import sys
import click
from .agent import default_agent, LocalBusinessExtractor
def setup_logging(verbose=False, debug=False):
"""Configure logging for execution visibility."""
if debug:
level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s"
elif verbose:
level, fmt = logging.INFO, "%(message)s"
else:
level, fmt = logging.WARNING, "%(levelname)s: %(message)s"
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
logging.getLogger("framework").setLevel(level)
@click.group()
@click.version_option(version="1.0.0")
def cli():
"""Local Business Extractor - Find businesses, extract contacts, sync to Sheets."""
pass
@cli.command()
@click.option(
"--query",
"-q",
type=str,
required=True,
help="Search query (e.g. 'bakeries in San Francisco')",
)
@click.option("--quiet", is_flag=True, help="Only output result JSON")
@click.option("--verbose", "-v", is_flag=True, help="Show execution details")
@click.option("--debug", is_flag=True, help="Show debug logging")
def run(query, quiet, verbose, debug):
"""Extract businesses matching a search query."""
if not quiet:
setup_logging(verbose=verbose, debug=debug)
context = {"user_request": query}
result = asyncio.run(default_agent.run(context))
output_data = {
"success": result.success,
"steps_executed": result.steps_executed,
"output": result.output,
}
if result.error:
output_data["error"] = result.error
click.echo(json.dumps(output_data, indent=2, default=str))
sys.exit(0 if result.success else 1)
@cli.command()
@click.option("--json", "output_json", is_flag=True)
def info(output_json):
"""Show agent information."""
info_data = default_agent.info()
if output_json:
click.echo(json.dumps(info_data, indent=2))
else:
click.echo(f"Agent: {info_data['name']}")
click.echo(f"Version: {info_data['version']}")
click.echo(f"Description: {info_data['description']}")
click.echo(f"\nNodes: {', '.join(info_data['nodes'])}")
click.echo(f"Entry: {info_data['entry_node']}")
click.echo(f"Terminal: {', '.join(info_data['terminal_nodes'])}")
@cli.command()
def validate():
"""Validate agent structure."""
validation = default_agent.validate()
if validation["valid"]:
click.echo("Agent is valid")
if validation["warnings"]:
for warning in validation["warnings"]:
click.echo(f" WARNING: {warning}")
else:
click.echo("Agent has errors:")
for error in validation["errors"]:
click.echo(f" ERROR: {error}")
sys.exit(0 if validation["valid"] else 1)
@cli.command()
@click.option("--verbose", "-v", is_flag=True)
def shell(verbose):
"""Interactive session (CLI)."""
asyncio.run(_interactive_shell(verbose))
async def _interactive_shell(verbose=False):
"""Async interactive shell."""
setup_logging(verbose=verbose)
click.echo("=== Local Business Extractor ===")
click.echo("Enter a search query (or 'quit' to exit):\n")
agent = LocalBusinessExtractor()
await agent.start()
try:
while True:
try:
query = await asyncio.get_event_loop().run_in_executor(
None, input, "Query> "
)
if query.lower() in ["quit", "exit", "q"]:
click.echo("Goodbye!")
break
if not query.strip():
continue
click.echo("\nExtracting...\n")
result = await agent.run({"user_request": query})
if result.success:
click.echo("\nExtraction complete\n")
else:
click.echo(f"\nExtraction failed: {result.error}\n")
except KeyboardInterrupt:
click.echo("\nGoodbye!")
break
except Exception as e:
click.echo(f"Error: {e}", err=True)
finally:
await agent.stop()
if __name__ == "__main__":
cli()
@@ -0,0 +1,205 @@
"""Agent graph construction for Local Business Extractor."""
from pathlib import Path
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
from framework.graph.edge import GraphSpec
from framework.graph.executor import ExecutionResult
from framework.graph.checkpoint_config import CheckpointConfig
from framework.llm import LiteLLMProvider
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.agent_runtime import create_agent_runtime
from framework.runtime.execution_stream import EntryPointSpec
from .config import default_config, metadata
from .nodes import map_search_gcu, extract_contacts_node, sheets_sync_node
goal = Goal(
id="local-business-extraction",
name="Local Business Extraction",
description="Find local businesses on Maps, extract contacts, and sync to Google Sheets.",
success_criteria=[
SuccessCriterion(
id="sc-1",
description="Extract business details from Maps",
metric="count",
target="5",
weight=0.5,
),
SuccessCriterion(
id="sc-2",
description="Sync data to Google Sheets",
metric="success_rate",
target="1.0",
weight=0.5,
),
],
constraints=[
Constraint(
id="c-1",
description="Must verify website presence before scraping",
constraint_type="hard",
category="quality",
),
],
)
nodes = [map_search_gcu, extract_contacts_node, sheets_sync_node]
edges = [
EdgeSpec(
id="extract-to-sheets",
source="extract-contacts",
target="sheets-sync",
condition=EdgeCondition.ON_SUCCESS,
priority=1,
),
# Loop back for new tasks
EdgeSpec(
id="sheets-to-extract",
source="sheets-sync",
target="extract-contacts",
condition=EdgeCondition.ALWAYS,
priority=1,
),
]
entry_node = "extract-contacts"
entry_points = {"start": "extract-contacts"}
pause_nodes = []
terminal_nodes = []
conversation_mode = "continuous"
identity_prompt = "You are a lead generation specialist focused on local businesses."
loop_config = {
"max_iterations": 100,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
}
class LocalBusinessExtractor:
def __init__(self, config=None):
self.config = config or default_config
self.goal = goal
self.nodes = nodes
self.edges = edges
self.entry_node = entry_node
self.entry_points = entry_points
self.pause_nodes = pause_nodes
self.terminal_nodes = terminal_nodes
self._graph = None
self._agent_runtime = None
self._tool_registry = None
self._storage_path = None
def _build_graph(self):
return GraphSpec(
id="local-business-extractor-graph",
goal_id=self.goal.id,
version="1.0.0",
entry_node=self.entry_node,
entry_points=self.entry_points,
terminal_nodes=self.terminal_nodes,
pause_nodes=self.pause_nodes,
nodes=self.nodes,
edges=self.edges,
default_model=self.config.model,
max_tokens=self.config.max_tokens,
loop_config=loop_config,
conversation_mode=conversation_mode,
identity_prompt=identity_prompt,
)
def _setup(self):
self._storage_path = (
Path.home() / ".hive" / "agents" / "local_business_extractor"
)
self._storage_path.mkdir(parents=True, exist_ok=True)
self._tool_registry = ToolRegistry()
mcp_config = Path(__file__).parent / "mcp_servers.json"
if mcp_config.exists():
self._tool_registry.load_mcp_config(mcp_config)
llm = LiteLLMProvider(
model=self.config.model,
api_key=self.config.api_key,
api_base=self.config.api_base,
)
tools = list(self._tool_registry.get_tools().values())
tool_executor = self._tool_registry.get_executor()
self._graph = self._build_graph()
self._agent_runtime = create_agent_runtime(
graph=self._graph,
goal=self.goal,
storage_path=self._storage_path,
entry_points=[
EntryPointSpec(
id="default",
name="Default",
entry_node=self.entry_node,
trigger_type="manual",
isolation_level="shared",
)
],
llm=llm,
tools=tools,
tool_executor=tool_executor,
checkpoint_config=CheckpointConfig(
enabled=True, checkpoint_on_node_complete=True
),
)
async def start(self):
if self._agent_runtime is None:
self._setup()
if not self._agent_runtime.is_running:
await self._agent_runtime.start()
async def stop(self):
if self._agent_runtime and self._agent_runtime.is_running:
await self._agent_runtime.stop()
self._agent_runtime = None
async def run(self, context, session_state=None):
await self.start()
try:
result = await self._agent_runtime.trigger_and_wait(
"default", context, session_state=session_state
)
return result or ExecutionResult(success=False, error="Execution timeout")
finally:
await self.stop()
def info(self):
"""Get agent information."""
return {
"name": metadata.name,
"version": metadata.version,
"description": metadata.description,
"goal": {
"name": self.goal.name,
"description": self.goal.description,
},
"nodes": [n.id for n in self.nodes],
"edges": [e.id for e in self.edges],
"entry_node": self.entry_node,
"entry_points": self.entry_points,
"pause_nodes": self.pause_nodes,
"terminal_nodes": self.terminal_nodes,
}
def validate(self):
"""Validate agent structure."""
errors = []
warnings = []
node_ids = {n.id for n in self.nodes}
for edge in self.edges:
if edge.source not in node_ids:
errors.append(f"Edge {edge.id}: source '{edge.source}' not found")
if edge.target not in node_ids:
errors.append(f"Edge {edge.id}: target '{edge.target}' not found")
if self.entry_node not in node_ids:
errors.append(f"Entry node '{self.entry_node}' not found")
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
default_agent = LocalBusinessExtractor()
@@ -0,0 +1,21 @@
"""Runtime configuration."""
from dataclasses import dataclass
from framework.config import RuntimeConfig
default_config = RuntimeConfig()
@dataclass
class AgentMetadata:
name: str = "Local Business Extractor"
version: str = "1.0.0"
description: str = (
"Extracts local businesses from Google Maps, scrapes contact details, "
"and syncs the results to Google Sheets."
)
intro_message: str = "I'm ready to extract business data. What should I search for?"
metadata = AgentMetadata()
@@ -0,0 +1,14 @@
{
"hive-tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "mcp_server.py", "--stdio"],
"cwd": "../../../tools"
},
"gcu-tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
"cwd": "../../../tools"
}
}
@@ -0,0 +1,86 @@
"""Node definitions for Local Business Extractor."""
from framework.graph import NodeSpec
# GCU Subagent for Google Maps
map_search_gcu = NodeSpec(
id="map-search-worker",
name="Maps Browser Worker",
description="Browser subagent that searches Google Maps and extracts business links.",
node_type="gcu",
client_facing=False,
max_node_visits=1,
input_keys=["query"],
output_keys=["business_list"],
tools=[], # Auto-populated with browser tools
system_prompt="""\
You are a browser agent. Your job: Search Google Maps for the provided query and extract business names and website URLs.
## Workflow
1. browser_start
2. browser_open(url="https://www.google.com/maps")
3. Use browser_type or browser_click to search for the "query" in memory.
4. browser_wait(seconds=3)
5. browser_snapshot to find the list of results.
6. For each relevant result, extract:
- Name of the business
- Website URL (look for the website icon/link)
7. set_output("business_list", [{"name": "...", "website": "..."}, ...])
## Constraints
- Extract at least 5-10 businesses if possible.
- If you see a "Website" button, extract that URL specifically.
""",
)
# Processing Node: Scrape & Prepare
extract_contacts_node = NodeSpec(
id="extract-contacts",
name="Extract Business Details",
description="Scrapes business websites and Maps for comprehensive business details.",
node_type="event_loop",
sub_agents=["map-search-worker"],
input_keys=["user_request"],
output_keys=["business_data"],
success_criteria="Comprehensive business details (reviews, hours, contacts) extracted.",
system_prompt="""\
1. Call delegate_to_sub_agent(agent_id="map-search-worker", task=user_request)
2. Receive "business_list" from memory.
3. For each business in the list:
- Use exa_get_contents or exa_search to find:
- Contact emails and phone numbers.
- Business hours.
- Customer reviews or ratings summary.
- Physical address.
4. Format the data into a comprehensive report for each business.
5. set_output("business_data", enriched_business_list)
""",
tools=["exa_get_contents", "exa_search"],
)
# Google Sheets Sync Node
sheets_sync_node = NodeSpec(
id="sheets-sync",
name="Google Sheets Sync",
description="Appends the extracted business data to a Google Sheets spreadsheet.",
node_type="event_loop",
input_keys=["business_data"],
output_keys=["spreadsheet_id"],
success_criteria="Data successfully synced to Google Sheets.",
system_prompt="""\
1. Check memory for "spreadsheet_id". If not set, create a new spreadsheet:
- Use google_sheets_create_spreadsheet(title="Comprehensive Business Leads")
- Save the spreadsheet ID with set_output("spreadsheet_id", id)
2. If the spreadsheet is new, write header row:
- Use google_sheets_update_values(spreadsheet_id=id, range_name="Sheet1!A1:G1", values=[["Name", "Website", "Email", "Phone", "Address", "Hours", "Reviews"]])
3. For each business in "business_data", append a row:
- Use google_sheets_append_values(spreadsheet_id=id, range_name="Sheet1!A:G", values=[[name, website, email, phone, address, hours, reviews]])
4. set_output("spreadsheet_id", id)
""",
tools=[
"google_sheets_create_spreadsheet",
"google_sheets_update_values",
"google_sheets_append_values",
"google_sheets_get_values",
],
)
@@ -0,0 +1,34 @@
"""Meeting Scheduler — Find available times on your calendar and book meetings."""
from .agent import (
MeetingScheduler,
default_agent,
goal,
nodes,
edges,
entry_node,
entry_points,
pause_nodes,
terminal_nodes,
conversation_mode,
identity_prompt,
loop_config,
)
from .config import default_config, metadata
__all__ = [
"MeetingScheduler",
"default_agent",
"goal",
"nodes",
"edges",
"entry_node",
"entry_points",
"pause_nodes",
"terminal_nodes",
"conversation_mode",
"identity_prompt",
"loop_config",
"default_config",
"metadata",
]
@@ -0,0 +1,131 @@
"""CLI entry point for Meeting Scheduler."""
import asyncio
import json
import logging
import sys
import click
from .agent import default_agent, MeetingScheduler
def setup_logging(verbose=False, debug=False):
if debug:
level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s"
elif verbose:
level, fmt = logging.INFO, "%(message)s"
else:
level, fmt = logging.WARNING, "%(levelname)s: %(message)s"
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
@click.group()
@click.version_option(version="1.0.0")
def cli():
"""Meeting Scheduler — Find available times on your calendar and book meetings."""
pass
@cli.command()
@click.option("--attendee", "-a", required=True, help="Attendee email address")
@click.option(
"--duration", "-d", type=int, required=True, help="Meeting duration in minutes"
)
@click.option("--title", "-t", required=True, help="Meeting title")
@click.option("--verbose", "-v", is_flag=True)
def run(attendee, duration, title, verbose):
"""Execute the scheduler."""
setup_logging(verbose=verbose)
result = asyncio.run(
default_agent.run(
{
"attendee_email": attendee,
"meeting_duration_minutes": str(duration),
"meeting_title": title,
}
)
)
click.echo(
json.dumps(
{"success": result.success, "output": result.output}, indent=2, default=str
)
)
sys.exit(0 if result.success else 1)
@cli.command()
def tui():
"""Launch TUI dashboard."""
from pathlib import Path
from framework.tui.app import AdenTUI
from framework.llm import LiteLLMProvider
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.agent_runtime import create_agent_runtime
from framework.runtime.execution_stream import EntryPointSpec
async def run_tui():
agent = MeetingScheduler()
agent._tool_registry = ToolRegistry()
storage = Path.home() / ".hive" / "agents" / "meeting_scheduler"
storage.mkdir(parents=True, exist_ok=True)
mcp_cfg = Path(__file__).parent / "mcp_servers.json"
if mcp_cfg.exists():
agent._tool_registry.load_mcp_config(mcp_cfg)
llm = LiteLLMProvider(
model=agent.config.model,
api_key=agent.config.api_key,
api_base=agent.config.api_base,
)
runtime = create_agent_runtime(
graph=agent._build_graph(),
goal=agent.goal,
storage_path=storage,
entry_points=[
EntryPointSpec(
id="start",
name="Start",
entry_node="intake",
trigger_type="manual",
isolation_level="isolated",
)
],
llm=llm,
tools=list(agent._tool_registry.get_tools().values()),
tool_executor=agent._tool_registry.get_executor(),
)
await runtime.start()
try:
app = AdenTUI(runtime)
await app.run_async()
finally:
await runtime.stop()
asyncio.run(run_tui())
@cli.command()
def info():
"""Show agent info."""
data = default_agent.info()
click.echo(
f"Agent: {data['name']}\nVersion: {data['version']}\nDescription: {data['description']}"
)
click.echo(
f"Nodes: {', '.join(data['nodes'])}\nClient-facing: {', '.join(data['client_facing_nodes'])}"
)
@cli.command()
def validate():
"""Validate agent structure."""
v = default_agent.validate()
if v["valid"]:
click.echo("Agent is valid")
else:
click.echo("Errors:")
for e in v["errors"]:
click.echo(f" {e}")
sys.exit(0 if v["valid"] else 1)
if __name__ == "__main__":
cli()
@@ -0,0 +1,257 @@
"""Agent graph construction for Meeting Scheduler."""
from pathlib import Path
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
from framework.graph.edge import GraphSpec
from framework.graph.executor import ExecutionResult
from framework.graph.checkpoint_config import CheckpointConfig
from framework.llm import LiteLLMProvider
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.agent_runtime import create_agent_runtime
from framework.runtime.execution_stream import EntryPointSpec
from .config import default_config, metadata
from .nodes import intake_node, schedule_node, confirm_node
# Goal definition
goal = Goal(
id="meeting-scheduler-goal",
name="Schedule Meetings",
description="Check calendar availability, find optimal meeting times, record meetings, and send reminders.",
success_criteria=[
SuccessCriterion(
id="sc-1",
description="Meeting time found within requested duration",
metric="calendar_availability",
target="success",
weight=0.35,
),
SuccessCriterion(
id="sc-2",
description="Meeting recorded in spreadsheet accurately",
metric="data_persistence",
target="recorded",
weight=0.30,
),
SuccessCriterion(
id="sc-3",
description="Attendee email reminder sent",
metric="communication",
target="sent",
weight=0.25,
),
SuccessCriterion(
id="sc-4",
description="User confirms meeting details",
metric="user_acknowledgment",
target="confirmed",
weight=0.10,
),
],
constraints=[
Constraint(
id="c-1",
description="Must use Google Calendar API for availability check",
constraint_type="hard",
category="functional",
),
Constraint(
id="c-2",
description="Meeting duration must match requested time",
constraint_type="hard",
category="accuracy",
),
Constraint(
id="c-3",
description="Spreadsheet record must include date, time, attendee, title",
constraint_type="hard",
category="quality",
),
],
)
# Node list
nodes = [intake_node, schedule_node, confirm_node]
# Edge definitions
edges = [
EdgeSpec(
id="intake-to-schedule",
source="intake",
target="schedule",
condition=EdgeCondition.ON_SUCCESS,
priority=1,
),
EdgeSpec(
id="schedule-to-confirm",
source="schedule",
target="confirm",
condition=EdgeCondition.ON_SUCCESS,
priority=1,
),
# Loop back for another booking
EdgeSpec(
id="confirm-to-intake",
source="confirm",
target="intake",
condition=EdgeCondition.CONDITIONAL,
condition_expr="str(next_action).lower() == 'another'",
priority=1,
),
]
# Graph configuration
entry_node = "intake"
entry_points = {"start": "intake"}
pause_nodes = []
terminal_nodes = [] # Forever-alive
# Module-level vars read by AgentRunner.load()
conversation_mode = "continuous"
identity_prompt = "You are a helpful meeting scheduler assistant that manages calendar availability and sends confirmations."
loop_config = {
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_history_tokens": 32000,
}
class MeetingScheduler:
def __init__(self, config=None):
self.config = config or default_config
self.goal = goal
self.nodes = nodes
self.edges = edges
self.entry_node = entry_node
self.entry_points = entry_points
self.pause_nodes = pause_nodes
self.terminal_nodes = terminal_nodes
self._graph = None
self._agent_runtime = None
self._tool_registry = None
self._storage_path = None
def _build_graph(self):
return GraphSpec(
id="meeting-scheduler-graph",
goal_id=self.goal.id,
version="1.0.0",
entry_node=self.entry_node,
entry_points=self.entry_points,
terminal_nodes=self.terminal_nodes,
pause_nodes=self.pause_nodes,
nodes=self.nodes,
edges=self.edges,
default_model=self.config.model,
max_tokens=self.config.max_tokens,
loop_config=loop_config,
conversation_mode=conversation_mode,
identity_prompt=identity_prompt,
)
def _setup(self):
self._storage_path = Path.home() / ".hive" / "agents" / "meeting_scheduler"
self._storage_path.mkdir(parents=True, exist_ok=True)
self._tool_registry = ToolRegistry()
mcp_config = Path(__file__).parent / "mcp_servers.json"
if mcp_config.exists():
self._tool_registry.load_mcp_config(mcp_config)
llm = LiteLLMProvider(
model=self.config.model,
api_key=self.config.api_key,
api_base=self.config.api_base,
)
tools = list(self._tool_registry.get_tools().values())
tool_executor = self._tool_registry.get_executor()
self._graph = self._build_graph()
self._agent_runtime = create_agent_runtime(
graph=self._graph,
goal=self.goal,
storage_path=self._storage_path,
entry_points=[
EntryPointSpec(
id="default",
name="Default",
entry_node=self.entry_node,
trigger_type="manual",
isolation_level="shared",
)
],
llm=llm,
tools=tools,
tool_executor=tool_executor,
checkpoint_config=CheckpointConfig(
enabled=True,
checkpoint_on_node_complete=True,
checkpoint_max_age_days=7,
async_checkpoint=True,
),
)
async def start(self):
if self._agent_runtime is None:
self._setup()
if not self._agent_runtime.is_running:
await self._agent_runtime.start()
async def stop(self):
if self._agent_runtime and self._agent_runtime.is_running:
await self._agent_runtime.stop()
self._agent_runtime = None
async def trigger_and_wait(
self, entry_point="default", input_data=None, timeout=None, session_state=None
):
if self._agent_runtime is None:
raise RuntimeError("Agent not started. Call start() first.")
return await self._agent_runtime.trigger_and_wait(
entry_point_id=entry_point,
input_data=input_data or {},
session_state=session_state,
)
async def run(self, context, session_state=None):
await self.start()
try:
result = await self.trigger_and_wait(
"default", context, session_state=session_state
)
return result or ExecutionResult(success=False, error="Execution timeout")
finally:
await self.stop()
def info(self):
return {
"name": metadata.name,
"version": metadata.version,
"description": metadata.description,
"goal": {"name": self.goal.name, "description": self.goal.description},
"nodes": [n.id for n in self.nodes],
"edges": [e.id for e in self.edges],
"entry_node": self.entry_node,
"entry_points": self.entry_points,
"terminal_nodes": self.terminal_nodes,
"client_facing_nodes": [n.id for n in self.nodes if n.client_facing],
}
def validate(self):
errors, warnings = [], []
node_ids = {n.id for n in self.nodes}
for e in self.edges:
if e.source not in node_ids:
errors.append(f"Edge {e.id}: source '{e.source}' not found")
if e.target not in node_ids:
errors.append(f"Edge {e.id}: target '{e.target}' not found")
if self.entry_node not in node_ids:
errors.append(f"Entry node '{self.entry_node}' not found")
for t in self.terminal_nodes:
if t not in node_ids:
errors.append(f"Terminal node '{t}' not found")
for ep_id, nid in self.entry_points.items():
if nid not in node_ids:
errors.append(f"Entry point '{ep_id}' references unknown node '{nid}'")
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
default_agent = MeetingScheduler()
@@ -0,0 +1,28 @@
"""Runtime configuration."""
from dataclasses import dataclass
from framework.config import RuntimeConfig
default_config = RuntimeConfig()
@dataclass
class AgentMetadata:
name: str = "Meeting Scheduler"
version: str = "1.0.0"
description: str = (
"Schedule meetings by checking Google Calendar availability, booking "
"optimal time slots, recording details in Google Sheets, and sending "
"email confirmations with Google Meet links to attendees."
)
intro_message: str = (
"Hi! I'm your meeting scheduler. Tell me who you'd like to meet with, "
"how long the meeting should be, and what it's about — I'll check "
"calendar availability, book a time slot, log it to your spreadsheet, "
"and send a confirmation email with a Google Meet link. "
"Who would you like to schedule a meeting with?"
)
metadata = AgentMetadata()
@@ -0,0 +1,9 @@
{
"hive-tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "mcp_server.py", "--stdio"],
"cwd": "../../../tools",
"description": "Hive tools MCP server"
}
}
@@ -0,0 +1,140 @@
"""Node definitions for Meeting Scheduler."""
from framework.graph import NodeSpec
# Node 1: Intake (client-facing)
intake_node = NodeSpec(
id="intake",
name="Intake",
description="Gather meeting details from the user",
node_type="event_loop",
client_facing=True,
max_node_visits=0,
input_keys=["attendee_email", "meeting_duration_minutes"],
output_keys=["attendee_email", "meeting_duration_minutes", "meeting_title"],
nullable_output_keys=[
"attendee_email",
"meeting_duration_minutes",
"meeting_title",
],
success_criteria="User has provided attendee email, meeting duration, and title.",
system_prompt="""\
You are a meeting scheduler assistant.
**STEP 1 Use ask_user to collect meeting details:**
1. Call ask_user to ask for: attendee email, meeting duration (minutes), and meeting title
2. Wait for the user's response before proceeding
**STEP 2 After user provides all details, call set_output:**
- set_output("attendee_email", "user's email address")
- set_output("meeting_duration_minutes", meeting duration as string)
- set_output("meeting_title", "title of the meeting")
""",
tools=[],
)
# Node 2: Schedule (autonomous)
schedule_node = NodeSpec(
id="schedule",
name="Schedule",
description="Find available time on calendar, book meeting with Google Meet, and log to Google Sheet",
node_type="event_loop",
max_node_visits=0,
input_keys=["attendee_email", "meeting_duration_minutes", "meeting_title"],
output_keys=[
"meeting_time",
"booking_confirmed",
"spreadsheet_recorded",
"email_sent",
"meet_link",
],
nullable_output_keys=[],
success_criteria="Meeting time found, Google Meet created, Google Sheet 'Meeting Scheduler' updated with date/time/attendee/title/meet_link, and confirmation email sent.",
system_prompt="""\
You are a meeting booking agent that creates Google Calendar events with Google Meet and logs to Google Sheets.
## STEP 1 — Calendar Operations (tool calls in this turn):
1. **Find availability and verify conflicts:**
- Use calendar_check_availability to find potential time slots.
- **CRITICAL:** Always search a broad window (at least 8 hours) for the target day to see the full context of the user's schedule.
- **SECONDARY CHECK:** Before finalizing a slot, use calendar_list_events for that specific day. This ensures you catch "soft" conflicts or events not marked as 'busy' that might still be important.
2. **Create the event WITH GOOGLE MEET (AUTOMATIC):**
- Use calendar_create_event with these parameters:
- summary: the meeting title
- start_time: the start datetime in ISO format (e.g., "2024-01-15T09:00:00")
- end_time: the end datetime in ISO format
- attendees: list with the attendee email address (e.g., ["user@example.com"])
- timezone: user's timezone (e.g., "America/Los_Angeles")
- IMPORTANT: The tool automatically generates a Google Meet link when attendees are provided.
You do NOT need to pass conferenceData - it is handled automatically.
- The response will include conferenceData.entryPoints with the Google Meet link
- Extract the meet_link from conferenceData.entryPoints[0].uri in the response
3. **Log to Google Sheets:**
- First, use google_sheets_get_spreadsheet with spreadsheet_id="Meeting Scheduler" to check if it exists
- If it doesn't exist, use google_sheets_create_spreadsheet with title="Meeting Scheduler"
- Then use google_sheets_append_values to add a row with:
- Date, Time, Attendee Email, Meeting Title, Google Meet Link
- The spreadsheet_id should be "Meeting Scheduler" (by name) or the ID returned from create
4. **Send confirmation email:**
- Use send_email to send the attendee a confirmation with:
- to: attendee email address
- subject: "Meeting Confirmation: {meeting_title}"
- body: Include meeting title, date/time, and Google Meet link
## STEP 2 — set_output (SEPARATE turn, no other tool calls):
After all tools complete successfully, call set_output:
- set_output("meeting_time", "YYYY-MM-DD HH:MM")
- set_output("meet_link", "https://meet.google.com/xxx/yyy")
- set_output("booking_confirmed", "true")
- set_output("spreadsheet_recorded", "true")
- set_output("email_sent", "true")
## CRITICAL: Google Meet creation
Google Meet links are AUTOMATICALLY created by calendar_create_event when attendees are provided.
Simply pass the attendees list and the tool will generate the Meet link.
""",
tools=[
"calendar_check_availability",
"calendar_create_event",
"calendar_list_events",
"google_sheets_create_spreadsheet",
"google_sheets_get_spreadsheet",
"google_sheets_append_values",
"send_email",
],
)
# Node 3: Confirm (client-facing)
confirm_node = NodeSpec(
id="confirm",
name="Confirm",
description="Present booking confirmation to user with Google Meet link",
node_type="event_loop",
client_facing=True,
max_node_visits=0,
input_keys=["meeting_time", "booking_confirmed", "meet_link"],
output_keys=["next_action"],
nullable_output_keys=["next_action"],
success_criteria="User has acknowledged the booking and received the Google Meet link.",
system_prompt="""\
You are a confirmation assistant.
**STEP 1 Present confirmation and ask user:**
1. Show the meeting details (date, time, attendee, title)
2. Display the Google Meet link prominently
3. Confirm the booking is complete and logged to Google Sheets
4. Call ask_user to ask if they want to schedule another meeting or finish
**STEP 2 After user responds, call set_output:**
- set_output("next_action", "another") if booking another meeting
- set_output("next_action", "done") if finished
""",
tools=[],
)
__all__ = ["intake_node", "schedule_node", "confirm_node"]
@@ -0,0 +1,34 @@
"""Test fixtures."""
import sys
from pathlib import Path
import pytest
_repo_root = Path(__file__).resolve().parents[4]
for _p in ["examples/templates", "core"]:
_path = str(_repo_root / _p)
if _path not in sys.path:
sys.path.insert(0, _path)
AGENT_PATH = str(Path(__file__).resolve().parents[1])
@pytest.fixture(scope="session")
def agent_module():
"""Import the agent package for structural validation."""
import importlib
return importlib.import_module(Path(AGENT_PATH).name)
@pytest.fixture(scope="session")
def runner_loaded():
"""Load the agent through AgentRunner (structural only, no LLM needed)."""
from framework.runner.runner import AgentRunner
from framework.credentials.models import CredentialError
try:
return AgentRunner.load(AGENT_PATH)
except CredentialError:
pytest.skip("Google OAuth credentials not configured")
@@ -0,0 +1,103 @@
"""Structural tests for Meeting Scheduler."""
from meeting_scheduler import (
default_agent,
goal,
nodes,
edges,
entry_node,
entry_points,
terminal_nodes,
conversation_mode,
loop_config,
)
class TestGoalDefinition:
def test_goal_exists(self):
assert goal is not None
assert goal.id == "meeting-scheduler-goal"
assert len(goal.success_criteria) == 4
assert len(goal.constraints) == 3
def test_success_criteria_weights_sum_to_one(self):
total = sum(sc.weight for sc in goal.success_criteria)
assert abs(total - 1.0) < 0.01
class TestNodeStructure:
def test_three_nodes(self):
assert len(nodes) == 3
assert nodes[0].id == "intake"
assert nodes[1].id == "schedule"
assert nodes[2].id == "confirm"
def test_intake_is_client_facing(self):
assert nodes[0].client_facing is True
def test_schedule_has_required_tools(self):
required = {
"calendar_check_availability",
"calendar_create_event",
"google_sheets_append_values",
"send_email",
}
actual = set(nodes[1].tools)
assert required.issubset(actual)
def test_confirm_is_client_facing(self):
assert nodes[2].client_facing is True
class TestEdgeStructure:
def test_three_edges(self):
assert len(edges) == 3
def test_linear_path(self):
assert edges[0].source == "intake"
assert edges[0].target == "schedule"
assert edges[1].source == "schedule"
assert edges[1].target == "confirm"
def test_loop_back(self):
assert edges[2].source == "confirm"
assert edges[2].target == "intake"
class TestGraphConfiguration:
def test_entry_node(self):
assert entry_node == "intake"
def test_entry_points(self):
assert entry_points == {"start": "intake"}
def test_forever_alive(self):
assert terminal_nodes == []
def test_conversation_mode(self):
assert conversation_mode == "continuous"
def test_loop_config_valid(self):
assert "max_iterations" in loop_config
assert "max_tool_calls_per_turn" in loop_config
assert "max_history_tokens" in loop_config
class TestAgentClass:
def test_default_agent_created(self):
assert default_agent is not None
def test_validate_passes(self):
result = default_agent.validate()
assert result["valid"] is True
assert len(result["errors"]) == 0
def test_agent_info(self):
info = default_agent.info()
assert info["name"] == "Meeting Scheduler"
assert "schedule" in [n for n in info["nodes"]]
class TestRunnerLoad:
def test_agent_runner_load_succeeds(self, runner_loaded):
assert runner_loaded is not None
@@ -0,0 +1,32 @@
# Twitter News Digest
Monitors tech Twitter profiles, extracts the latest tweets, and compiles a daily tech news digest with user review.
## Nodes
| Node | Type | Description |
|------|------|-------------|
| `fetch-tweets` | `gcu` (browser) | Navigates to Twitter profiles and extracts latest tweets |
| `process-news` | `event_loop` | Analyzes and summarizes tweets into a tech digest |
| `review-digest` | `event_loop` (client-facing) | Presents digest for user review and feedback |
## Flow
```
process-news → review-digest → (loop back to process-news)
↓ ↑
fetch-tweets feedback loop (if revisions needed)
(sub-agent)
```
## Tools used
- **save_data / load_data** — persist daily reports
- **Browser (GCU)** — automated Twitter browsing and tweet extraction
## Running
```bash
uv run python -m examples.templates.twitter_news_agent run
uv run python -m examples.templates.twitter_news_agent run --handles "@TechCrunch,@verge,@WIRED"
```
@@ -0,0 +1,34 @@
"""Twitter News Digest — monitors Twitter for news."""
from .agent import (
TwitterNewsAgent,
default_agent,
goal,
nodes,
edges,
entry_node,
entry_points,
pause_nodes,
terminal_nodes,
conversation_mode,
identity_prompt,
loop_config,
)
from .config import default_config, metadata
__all__ = [
"TwitterNewsAgent",
"default_agent",
"goal",
"nodes",
"edges",
"entry_node",
"entry_points",
"pause_nodes",
"terminal_nodes",
"conversation_mode",
"identity_prompt",
"loop_config",
"default_config",
"metadata",
]
@@ -0,0 +1,148 @@
"""
CLI entry point for Twitter News Digest.
"""
import asyncio
import json
import logging
import sys
import click
from .agent import default_agent, TwitterNewsAgent
def setup_logging(verbose=False, debug=False):
"""Configure logging for execution visibility."""
if debug:
level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s"
elif verbose:
level, fmt = logging.INFO, "%(message)s"
else:
level, fmt = logging.WARNING, "%(levelname)s: %(message)s"
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
logging.getLogger("framework").setLevel(level)
@click.group()
@click.version_option(version="1.1.0")
def cli():
"""Twitter News Digest - Monitor Twitter feeds for tech news."""
pass
@cli.command()
@click.option(
"--handles",
"-h",
type=str,
default=None,
help="Comma-separated Twitter handles to monitor",
)
@click.option("--quiet", is_flag=True, help="Only output result JSON")
@click.option("--verbose", "-v", is_flag=True, help="Show execution details")
@click.option("--debug", is_flag=True, help="Show debug logging")
def run(handles, quiet, verbose, debug):
"""Fetch and summarize tech news from Twitter."""
if not quiet:
setup_logging(verbose=verbose, debug=debug)
context = {"user_request": "Fetch the latest tech news digest from Twitter"}
if handles:
context["twitter_handles"] = [h.strip() for h in handles.split(",")]
result = asyncio.run(default_agent.run(context))
output_data = {
"success": result.success,
"steps_executed": result.steps_executed,
"output": result.output,
}
if result.error:
output_data["error"] = result.error
click.echo(json.dumps(output_data, indent=2, default=str))
sys.exit(0 if result.success else 1)
@cli.command()
@click.option("--json", "output_json", is_flag=True)
def info(output_json):
"""Show agent information."""
info_data = default_agent.info()
if output_json:
click.echo(json.dumps(info_data, indent=2))
else:
click.echo(f"Agent: {info_data['name']}")
click.echo(f"Version: {info_data['version']}")
click.echo(f"Description: {info_data['description']}")
click.echo(f"\nNodes: {', '.join(info_data['nodes'])}")
click.echo(f"Entry: {info_data['entry_node']}")
click.echo(f"Terminal: {', '.join(info_data['terminal_nodes'])}")
@cli.command()
def validate():
"""Validate agent structure."""
validation = default_agent.validate()
if validation["valid"]:
click.echo("Agent is valid")
if validation["warnings"]:
for warning in validation["warnings"]:
click.echo(f" WARNING: {warning}")
else:
click.echo("Agent has errors:")
for error in validation["errors"]:
click.echo(f" ERROR: {error}")
sys.exit(0 if validation["valid"] else 1)
@cli.command()
@click.option("--verbose", "-v", is_flag=True)
def shell(verbose):
"""Interactive session (CLI)."""
asyncio.run(_interactive_shell(verbose))
async def _interactive_shell(verbose=False):
"""Async interactive shell."""
setup_logging(verbose=verbose)
click.echo("=== Twitter News Digest ===")
click.echo("Enter a request (or 'quit' to exit):\n")
agent = TwitterNewsAgent()
await agent.start()
try:
while True:
try:
query = await asyncio.get_event_loop().run_in_executor(
None, input, "News> "
)
if query.lower() in ["quit", "exit", "q"]:
click.echo("Goodbye!")
break
if not query.strip():
continue
click.echo("\nFetching news...\n")
result = await agent.run({"user_request": query})
if result.success:
click.echo("\nDigest complete\n")
else:
click.echo(f"\nDigest failed: {result.error}\n")
except KeyboardInterrupt:
click.echo("\nGoodbye!")
break
except Exception as e:
click.echo(f"Error: {e}", err=True)
finally:
await agent.stop()
if __name__ == "__main__":
cli()
@@ -0,0 +1,241 @@
"""Agent graph construction for Twitter News Digest."""
from pathlib import Path
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
from framework.graph.edge import GraphSpec
from framework.graph.executor import ExecutionResult
from framework.graph.checkpoint_config import CheckpointConfig
from framework.llm import LiteLLMProvider
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.agent_runtime import create_agent_runtime
from framework.runtime.execution_stream import EntryPointSpec
from .config import default_config, metadata
from .nodes import fetch_node, process_node, review_node
# Goal definition
goal = Goal(
id="twitter-news-goal",
name="Twitter News Digest",
description="Achieve an accurate and concise daily news digest based on Twitter feed monitoring.",
success_criteria=[
SuccessCriterion(
id="sc-1",
description="Navigate and extract tweets from at least 3 handles.",
metric="handle_count",
target=">=3",
weight=0.4,
),
SuccessCriterion(
id="sc-2",
description="Provide a summary of the most important stories.",
metric="summary_quality",
target="high",
weight=0.4,
),
SuccessCriterion(
id="sc-3",
description="Maintain a persistent log of daily digests.",
metric="file_exists",
target="true",
weight=0.2,
),
],
constraints=[
Constraint(
id="c-1",
description="Respect rate limits and ethical web usage.",
constraint_type="hard",
category="functional",
),
],
)
# Node list
nodes = [fetch_node, process_node, review_node]
# Edge definitions
edges = [
# Process tweets then review
EdgeSpec(
id="process-to-review",
source="process-news",
target="review-digest",
condition=EdgeCondition.ON_SUCCESS,
priority=1,
),
# Feedback loop if revisions needed
EdgeSpec(
id="review-to-process",
source="review-digest",
target="process-news",
condition=EdgeCondition.CONDITIONAL,
condition_expr="str(status).lower() == 'revise'",
priority=2,
),
# Loop back for next run (forever-alive)
EdgeSpec(
id="review-done",
source="review-digest",
target="process-news",
condition=EdgeCondition.CONDITIONAL,
condition_expr="str(status).lower() == 'approved'",
priority=1,
),
]
# Entry point is the autonomous processing node (queen handles intake)
entry_node = "process-news"
entry_points = {"start": "process-news"}
pause_nodes = []
terminal_nodes = [] # Forever-alive
# Module-level vars read by AgentRunner.load()
conversation_mode = "continuous"
identity_prompt = "You are a professional news analyst and researcher."
loop_config = {
"max_iterations": 100,
"max_tool_calls_per_turn": 20,
"max_history_tokens": 32000,
}
class TwitterNewsAgent:
def __init__(self, config=None):
self.config = config or default_config
self.goal = goal
self.nodes = nodes
self.edges = edges
self.entry_node = entry_node
self.entry_points = entry_points
self.pause_nodes = pause_nodes
self.terminal_nodes = terminal_nodes
self._graph = None
self._agent_runtime = None
self._tool_registry = None
self._storage_path = None
def _build_graph(self):
return GraphSpec(
id="twitter-news-graph",
goal_id=self.goal.id,
version="1.0.0",
entry_node=self.entry_node,
entry_points=self.entry_points,
terminal_nodes=self.terminal_nodes,
pause_nodes=self.pause_nodes,
nodes=self.nodes,
edges=self.edges,
default_model=self.config.model,
max_tokens=self.config.max_tokens,
loop_config=loop_config,
conversation_mode=conversation_mode,
identity_prompt=identity_prompt,
)
def _setup(self):
self._storage_path = Path.home() / ".hive" / "agents" / "twitter_news_agent"
self._storage_path.mkdir(parents=True, exist_ok=True)
self._tool_registry = ToolRegistry()
mcp_config = Path(__file__).parent / "mcp_servers.json"
if mcp_config.exists():
self._tool_registry.load_mcp_config(mcp_config)
llm = LiteLLMProvider(
model=self.config.model,
api_key=self.config.api_key,
api_base=self.config.api_base,
)
tools = list(self._tool_registry.get_tools().values())
tool_executor = self._tool_registry.get_executor()
self._graph = self._build_graph()
self._agent_runtime = create_agent_runtime(
graph=self._graph,
goal=self.goal,
storage_path=self._storage_path,
entry_points=[
EntryPointSpec(
id="default",
name="Default",
entry_node=self.entry_node,
trigger_type="manual",
isolation_level="shared",
)
],
llm=llm,
tools=tools,
tool_executor=tool_executor,
checkpoint_config=CheckpointConfig(
enabled=True,
checkpoint_on_node_complete=True,
checkpoint_max_age_days=7,
async_checkpoint=True,
),
)
async def start(self):
if self._agent_runtime is None:
self._setup()
if not self._agent_runtime.is_running:
await self._agent_runtime.start()
async def stop(self):
if self._agent_runtime and self._agent_runtime.is_running:
await self._agent_runtime.stop()
self._agent_runtime = None
async def trigger_and_wait(
self, entry_point="default", input_data=None, timeout=None, session_state=None
):
if self._agent_runtime is None:
raise RuntimeError("Agent not started. Call start() first.")
return await self._agent_runtime.trigger_and_wait(
entry_point_id=entry_point,
input_data=input_data or {},
session_state=session_state,
)
async def run(self, context, session_state=None):
await self.start()
try:
result = await self.trigger_and_wait(
"default", context, session_state=session_state
)
return result or ExecutionResult(success=False, error="Execution timeout")
finally:
await self.stop()
def info(self):
return {
"name": metadata.name,
"version": metadata.version,
"description": metadata.description,
"goal": {"name": self.goal.name, "description": self.goal.description},
"nodes": [n.id for n in self.nodes],
"edges": [e.id for e in self.edges],
"entry_node": self.entry_node,
"entry_points": self.entry_points,
"terminal_nodes": self.terminal_nodes,
"client_facing_nodes": [n.id for n in self.nodes if n.client_facing],
}
def validate(self):
errors, warnings = [], []
node_ids = {n.id for n in self.nodes}
for e in self.edges:
if e.source not in node_ids:
errors.append(f"Edge {e.id}: source '{e.source}' not found")
if e.target not in node_ids:
errors.append(f"Edge {e.id}: target '{e.target}' not found")
if self.entry_node not in node_ids:
errors.append(f"Entry node '{self.entry_node}' not found")
for t in self.terminal_nodes:
if t not in node_ids:
errors.append(f"Terminal node '{t}' not found")
for ep_id, nid in self.entry_points.items():
if nid not in node_ids:
errors.append(f"Entry point '{ep_id}' references unknown node '{nid}'")
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
default_agent = TwitterNewsAgent()
@@ -0,0 +1,20 @@
"""Runtime configuration."""
from dataclasses import dataclass
from framework.config import RuntimeConfig
default_config = RuntimeConfig()
@dataclass
class AgentMetadata:
name: str = "Twitter News Digest"
version: str = "1.1.0"
description: str = (
"Monitors Twitter feeds and provides a daily news digest, focused on tech news."
)
intro_message: str = "I'm ready to fetch the latest tech news from Twitter. Which tech handles should I check?"
metadata = AgentMetadata()
@@ -0,0 +1,16 @@
{
"hive-tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "mcp_server.py", "--stdio"],
"cwd": "../../../tools",
"description": "Hive tools MCP server"
},
"gcu-tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
"cwd": "../../../tools",
"description": "GCU tools for browser automation"
}
}

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