Compare commits

..

322 Commits

Author SHA1 Message Date
openhands
c59ff6e335 Fix pyproject-fmt linter errors in enterprise/pyproject.toml
- Alphabetically sort test dependencies (move gevent to proper position)
- Reorder [tool.coverage.run] section to appear before [tool.uv.sources]

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-10 19:00:42 +00:00
Xingyao Wang
7e55d16ed1 Merge commit '408f8aa50f52547546e43d87781013c10cf77386' into migrate-to-uv 2025-10-04 10:14:20 -04:00
caocao-sys
408f8aa50f fix: RollingLogger move_back method ignoring amount parameter (#10874) (#10875)
Co-authored-by: shikaixuan <shikaixuan@baidu.com>
2025-10-03 22:49:33 +02:00
mamoodi
025ac7672f Update docs (#11224) 2025-10-03 14:29:24 -04:00
Yakshith
aab6f4127c fix(frontend): improve chat input status indicator responsiveness (#11202)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: chuckbutkus <chuck@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Robert Brennan <accounts@rbren.io>
Co-authored-by: Jamie Chicago <87397251+jamiechicago312@users.noreply.github.com>
2025-10-03 01:36:38 +07:00
Hiep Le
c932cd0815 fix(frontend): org level microagents not appearing (microagent management) (#11218) 2025-10-02 22:19:02 +07:00
Hiep Le
d3395172f8 fix(frontend): search repos (microagent management) (#11217) 2025-10-02 22:18:43 +07:00
sp.wack
1e8851b244 feat: V1 client types (#11216)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-02 15:02:37 +00:00
Jamie Chicago
167fb3f429 Replace dub.sh/openhands links with all-hands.dev/joinslack (#11209)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-01 20:00:00 +00:00
Robert Brennan
df4d30addf Add POST /api/conversations/{id}/message endpoint (#11177)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-01 15:35:29 -04:00
chuckbutkus
37daf068c5 Allow custom user (#11013)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-01 17:48:25 +00:00
mamoodi
5452abe513 Release 0.58.0 (#11168) 2025-10-01 13:18:20 -04:00
Hiep Le
a8b6406dac fix: searching repositories (#11203) 2025-10-01 22:21:20 +07:00
Hiep Le
509d4a9513 fix(frontend): the horizontal scrollbar is not displayed for the markdown content (#11200) 2025-10-01 12:31:56 +07:00
Alona
d099c21f5d fix: add provider tokens to resume conversation endpoint (#11155) 2025-10-01 05:20:48 +07:00
Hiep Le
4c89b5ad91 fix: showing unrelated files in the changes tab (#11185)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-10-01 01:44:36 +07:00
sp.wack
729c181313 Revert "fix(backend): Add validation for LLM settings to prevent non-pro user bypass" (#11192) 2025-09-30 17:54:33 +00:00
Hiep Le
2eb3a9e6ad refactor(frontend): migration of use-ws-error-message.ts to zustand (#11147) 2025-09-30 22:51:17 +07:00
Hiep Le
2382baacc2 feat(frontend): persist recent repos (#11171) 2025-09-30 22:15:36 +07:00
dependabot[bot]
98ce55e2fc chore(deps): bump the version-all group across 1 directory with 12 updates (#11174)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-30 13:18:09 +00:00
Ray Myers
c929447624 Tag enterprise-server image with PR Head (#11182) 2025-09-30 08:58:20 -04:00
Hiep Le
2cc77fb034 refactor(frontend): migration of use-optimistic-user-message.ts to zustand (#11148) 2025-09-30 19:56:07 +07:00
Hiep Le
d57462e8ca refactor(frontend): update the not found message displayed when no repositories match the search query (#11187) 2025-09-30 19:55:52 +07:00
Hiep Le
1e23017bb1 fix(frontend): pause agent button (chat interface) (#11172) 2025-09-30 19:55:25 +07:00
Hiep Le
3493348fac refactor(frontend): align chat message action buttons (#11173) 2025-09-30 19:55:09 +07:00
Web3 Outlaw
e63d981192 Upgrade Volta Node to 22.x (#11166) 2025-09-30 16:47:33 +04:00
Xingyao Wang
e19b3dd1f0 Add claude-sonnet-4-5 model support (#11179)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-30 03:27:19 +00:00
Ray Myers
c3da6c20bd chore - Upgrade to Debian Trixie in app image and runtime image option (#11114) 2025-09-29 21:08:48 +00:00
mamoodi
a022f505a8 Remove legacy README_CN.md and README_JA.md translation files (#11169)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-29 22:08:59 +02:00
Hiep Le
04196f8d53 fix(frontend): duplicate LLM navigation items in settings sidebar (#11176) 2025-09-29 15:34:04 -04:00
mamoodi
dcf00c34fa remove broken screenshot (#11167) 2025-09-29 09:35:57 -04:00
Robert Brennan
d4e94b32e1 Remove missing user message check for REMOTE_API_KEY (#11164) 2025-09-29 08:57:04 +08:00
Engel Nyst
a1b81fe923 Enable native tool calling for Anthropic Claude 3.5 Sonnet (dot-notation) models (#11156)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-28 00:38:37 +02:00
mamoodi
e6d799c51a Remove discord links as it winds down and some small doc updates (#11145) 2025-09-27 08:00:03 -04:00
sp.wack
fb6f688049 refactor(frontend): convert settings to vertical sidebar layout (#10971)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-26 13:57:37 +00:00
sp.wack
ef12adc107 fix(backend): Add validation for LLM settings to prevent non-pro user bypass (#11113) 2025-09-26 16:10:09 +04:00
Hiep Le
8a7a5cce5e refactor(frontend): remove store.ts file (#11119)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-26 10:03:18 +07:00
mamoodi
b883fe37e6 fix MCP links (#11137) 2025-09-25 23:32:19 +00:00
mamoodi
182b7adcab Clean up MCP docs and move it to settings (#11133) 2025-09-25 18:28:29 -04:00
mamoodi
63829d0f45 Fix help links for OpenHands Provider (#11128)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-25 19:04:48 +00:00
jpelletier1
830a9e027f docs: Add Pro Subscription documentation page (#11115)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-25 13:07:30 -04:00
mamoodi
120a5d6ebd Add new settings folder for better organization (#11118) 2025-09-25 12:06:10 -04:00
mamoodi
6b1d1869f3 Run pypi workflow on tags (#11112) 2025-09-25 14:03:01 +00:00
Hiep Le
e376c2bfd1 refactor(frontend): migration of agent-slice.ts to zustand (#11102) 2025-09-25 12:44:21 +07:00
mamoodi
f8f74858da Update parts of the OpenHands Cloud docs (#11107) 2025-09-24 16:27:52 -04:00
Ray Myers
848a884b04 chore - Track Python test coverage (#11072) 2025-09-24 15:27:34 -05:00
Hiep Le
88a58a1748 refactor(frontend): migration of jupyter-slice.ts to zustand (#11019) 2025-09-25 00:56:55 +07:00
Hiep Le
f59ea69b70 refactor(frontend): migration of microagent-management-slice.ts to zustand (#11033) 2025-09-24 23:58:17 +07:00
dependabot[bot]
8f004a1f6d chore(deps-dev): bump @tanstack/eslint-plugin-query from 5.86.0 to 5.90.1 in /frontend in the eslint group (#11101)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 20:53:20 +04:00
sp.wack
15b4690ebf feat(frontend): Animate conversation panels (#11099) 2025-09-24 16:41:19 +00:00
Hiep Le
df1c5bbf85 refactor(frontend): migration of event-message-slice.ts to zustand (#11080) 2025-09-24 22:53:45 +07:00
Hiep Le
8adbb76bd7 refactor(frontend): migration of browser-slice.ts to zustand (#11081) 2025-09-24 22:52:48 +07:00
Hiep Le
0095672439 feat(frontend): keyboard shortcuts for file copy and paste (#11096) 2025-09-24 22:52:03 +07:00
Hiep Le
6a5d09660d fix(frontend): upgrade banner covers conversation panel in settings (#11094) 2025-09-24 21:40:16 +07:00
Hiep Le
a94906e15c refactor(frontend): migration of security-analyzer-slice.ts to zustand (#11082) 2025-09-24 21:32:25 +07:00
Hiep Le
12dc256b5a refactor(frontend): git actions should be enabled at all times (#11063) 2025-09-24 21:31:51 +07:00
Hiep Le
11edf33b97 refactor(frontend): remove file-state-slice.ts file (#11061) 2025-09-24 21:31:11 +07:00
Hiep Le
fce66e94e7 refactor(frontend): event message (#11001) 2025-09-24 21:30:55 +07:00
Hiep Le
5457392eae refactor(frontend): migration of conversation-slice.ts to zustand (#11032) 2025-09-24 21:30:37 +07:00
Ray Myers
1e7024b60a fix - Set claude sonnet output limit (#11098) 2025-09-24 13:31:20 +00:00
Hiep Le
3977d4fdd7 fix(frontend): insufficient spacing between last message and chat input (#11055) 2025-09-24 20:13:21 +07:00
Tejas Goyal
16004426a2 feat: Add configurable timeouts for MCP tool invocations (Good first issues: #10684) (#11029)
Co-authored-by: Tejas Goyal <tejas@Tejass-MacBook-Pro.local>
2025-09-24 16:43:54 +04:00
dependabot[bot]
73eb53a379 chore(deps): bump the version-all group across 1 directory with 21 updates (#11078)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 16:24:27 +04:00
BenYao21
d3d70fcc60 issue #9388, this will fix the issue (#10450)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-09-22 16:56:53 -04:00
Xinyi He
7906eab6b1 Add inference generation of SWE-Perf Benchmark (#10246)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-22 20:35:30 +00:00
juanmichelini
547e1049f1 Multi swe gym (#10605)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-22 15:56:26 -04:00
mamoodi
818cc60b52 New label for not going stale (#11069) 2025-09-22 11:53:47 -04:00
Robert Brennan
431d2c1f43 security: upgrade setuptools to >=78.1.1 to address CVE-2025-47273 and CVE-2024-6345 (#11038)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: enyst <engel.nyst@gmail.com>
2025-09-22 04:05:45 +00:00
Engel Nyst
07f23641a3 build(deps): pin litellm to avoid build failure (#11054)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-22 03:54:37 +02:00
Hiep Le
de84af5586 feat(frontend): display lock icon when confirmation mode is enabled (#11030) 2025-09-20 10:55:19 +07:00
Hiep Le
b7765ba3f7 refactor(frontend): fix typecheck (#11037) 2025-09-19 13:43:00 -04:00
Hiep Le
b89f2e51e4 refactor(frontend): migration of metrics-slice.ts to zustand (#11018) 2025-09-19 23:52:21 +07:00
mamoodi
e09f93aa75 Release 0.57.0 (#10981)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-09-19 12:40:56 -04:00
Hiep Le
9f529b105a refactor(frontend): migration of command-slice.ts to zustand (#11003) 2025-09-19 23:33:59 +07:00
Graham Neubig
89e3d2a867 Improve OpenHands provider pricing documentation (#10974)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-20 00:22:44 +08:00
Hiep Le
a7b9a4f291 refactor(frontend): migration of status-slice.ts to zustand (#11017) 2025-09-19 22:27:55 +07:00
Hiep Le
88cd16ae21 refactor(frontend): migration of initial-query-slice.ts to zustand (#11020) 2025-09-19 22:27:20 +07:00
Hiep Le
a8a3e9e604 refactor(frontend): remove the code-slice.ts file (#11021) 2025-09-19 21:22:29 +07:00
Hiep Le
0061bcc0b0 refactor(frontend): custom chat input (#10984) 2025-09-19 21:06:18 +07:00
Hiep Le
9c9fa780b0 refactor(frontend): task tracking observation content (#11002) 2025-09-19 20:03:05 +07:00
Alona
569ac16163 Improve token refresh error logging (#11026) 2025-09-19 14:18:38 +07:00
Robert Brennan
46f7738f41 Update Python packages to latest versions (#11023)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 19:52:46 +00:00
Rohit Malhotra
3f3669dd34 Hotfix: rm model choice override (#11022) 2025-09-18 14:40:06 -04:00
sp.wack
cd65645eea Hide Tavily search API key help text in SaaS mode (#11014)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 16:40:29 +00:00
Robert Brennan
8e88a7a277 fix: resolve critical and high CVEs in enterprise Docker image (#10987)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 11:25:33 -04:00
Hiep Le
b393d52439 refactor(frontend): conversation main (#10985) 2025-09-18 20:23:13 +07:00
Hiep Le
faeec48365 refactor(frontend): conversation card (#10986) 2025-09-18 20:22:59 +07:00
sp.wack
774caf0607 feat: refactor status indicators (#10983)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 22:32:55 +04:00
sp.wack
7222730df0 Fix SaaS callback URLs and pro pill positioning (#10998)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 16:56:02 +00:00
Hiep Le
910177fc57 refactor(frontend): system message modal (#10969) 2025-09-17 21:56:14 +07:00
Hiep Le
ac9badbd20 refactor(frontend): metrics modal (#10968) 2025-09-17 21:55:25 +07:00
Ray Myers
02c299d88f Fix Slack resolver failing on AWAITING_USER_INPUT state (#10992)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 09:20:12 -05:00
mamoodi
f65fbef649 Remove runtime settings (#10996) 2025-09-17 13:59:29 +00:00
Hiep Le
3c2acad28d refactor(frontend): microagents modal (#10970) 2025-09-16 22:32:23 +07:00
Jakob Stender Guldberg
f5a4834cee Merge branch 'main' into migrate-to-uv 2025-09-16 08:42:45 +02:00
Boxuan Li
0f1780728e Update str_replace_editor tool to use dynamic workspace path from SANDBOX_VOLUMES (#10965)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-15 17:46:54 -07:00
sp.wack
d3f3378a4c feat: Upgrade banner for unsubscribed SaaS users (#10890)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-15 23:04:44 +00:00
Engel Nyst
65f4164749 [Docs] Add environment variables reference table (#10926)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-15 18:31:44 +00:00
Hiep Le
3f984d878b refactor(frontend): move conversation APIs to a dedicated service handler (#10957) 2025-09-16 00:57:15 +07:00
Eliot Jones
10b871f4ab feat: Add Cygnal integration (#10898) 2025-09-15 09:57:03 -04:00
Hiep Le
d664f516db refactor(frontend): conversation tab content component (#10956) 2025-09-15 20:56:38 +07:00
Hiep Le
e74bbd81d1 fix(frontend): suppressing event display in the absence of user messages (#10955) 2025-09-15 20:56:16 +07:00
Hiep Le
ab893f93f0 refactor(frontend): use-auto-resize hook (#10959) 2025-09-15 20:49:15 +07:00
Hiep Le
5aba498e77 refactor(frontend): move billing APIs to a dedicated service handler (#10958) 2025-09-15 20:37:07 +07:00
Hiep Le
1523555eea refactor(frontend): remove dead code (#10839) 2025-09-15 20:35:56 +07:00
Jakob Stender Guldberg
f1f983cfce fix: removed ipython tests as the are about to become obsolete 2025-09-15 15:17:00 +02:00
jakob1379
6ea6ed739f test(runtime/ipython): make Jupyter/uv assertions runtime-agnostic and accept uv output
- Relax hardcoded interpreter path and cwd assertions to support uv and LocalRuntime
- Accept uv %pip output formats (Installed N package[s], Using Python env)

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-13 09:28:29 +00:00
Jakob Stender Guldberg
153d006db3 fix: uv dynamic version and hatch are not friends 2025-09-13 09:12:09 +02:00
Kaushik Ashodiya
30604c40fc fix: improve CLI help and version command performance (#10908)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-12 14:23:01 -04:00
Hiep Le
8dc46b7206 refactor(frontend): optimize pre-commit lint script (#10870)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-09-12 15:23:29 +00:00
Hiep Le
69498bebb4 refactor(frontend): new conversation component (#10937)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-12 22:15:26 +07:00
tksrmz
77ee9e25d9 fix(frontend): highlight preceding stars on hover in LikertScale (#10948) 2025-09-12 18:01:40 +04:00
Jakob Stender Guldberg
b5c2e89404 fix: ipython test should use uv 2025-09-12 14:45:42 +02:00
Hiep Le
74753036bb refactor(frontend): move user APIs to a dedicated service handler (#10943) 2025-09-12 09:08:15 +07:00
Hiep Le
95d7c10608 refactor(frontend): move option APIs to a dedicated service handler (#10933) 2025-09-12 00:43:15 +07:00
Hiep Le
c142cc27ff refactor(frontend): home header component (#10930) 2025-09-12 00:10:58 +07:00
Hiep Le
0e20fc206b refactor(frontend): move settings APIs to a dedicated service handler (#10941) 2025-09-11 23:39:23 +07:00
Hiep Le
e21475a88e feat(frontend): persist drawer open/close state on page refresh (#10935)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-11 15:58:00 +00:00
Hiep Le
921fec0019 refactor(frontend): expand repository pill to full available width (#10936) 2025-09-11 22:37:44 +07:00
Hiep Le
049f839a62 refactor(frontend): move auth APIs to a dedicated service handler (#10932) 2025-09-11 22:31:41 +07:00
Hiep Le
0dde758e13 refactor(frontend): move microagent management API to a dedicated service handler (#10934) 2025-09-11 22:27:56 +07:00
Tim O'Farrell
8257ae70cc Additional logs to debug container working directories (#10902)
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
2025-09-11 11:06:19 -04:00
Ray Myers
4513bcc622 chore - MyPy check Enterprise with OpenHands (#10858)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-09-11 11:05:50 -04:00
Hiep Le
b5b9a3f40b refactor(frontend): create waiting for runtime component (#10931) 2025-09-11 21:30:05 +07:00
Jakob Stender Guldberg
500cb76fe1 ci(runtime tests): jupyter relies on python -m pip 2025-09-11 11:56:21 +02:00
Jakob Stender Guldberg
c6877a2b9c ci: apply minimal workflow permissions and guard PR artifact comment 2025-09-11 09:40:22 +02:00
Jakob Stender Guldberg
3716c50fdf added pytest-rerunfailures to test-group 2025-09-11 09:18:08 +02:00
Xingyao Wang
8ea1259943 Add GitHub workflow for MDX format checking and fix parsing error (#10924)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-10 23:04:54 +00:00
Ray Myers
ddb2794adf fix - Tag enterprise with the same SHA as app image. (#10921) 2025-09-10 16:47:31 -05:00
Jakob Stender Guldberg
161980310d fix: export python path for multiple commands 2025-09-10 22:58:08 +02:00
sp.wack
79fdcad7ef Fix status indicator and chat input synchronization issue (#10914)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-10 20:39:14 +00:00
chuckbutkus
1de70b8ce4 Fix runtime init (#10909) 2025-09-10 19:28:12 +00:00
sp.wack
3baeecb27c meta(frontend): Improve UX (#9845)
Co-authored-by: Mislav Lukach <mislavlukach@gmail.com>
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-10 18:12:52 +00:00
Jakob Stender Guldberg
0435a45aa8 fix: accidentally removed rerun 2025-09-10 16:47:19 +02:00
Jakob Stender Guldberg
e4147800c5 fix: OH suggested to add the pythonpath like with unittest 2025-09-10 16:31:01 +02:00
Jakob Stender Guldberg
1a3b740c9c make sure to actually install openhands.... 2025-09-10 12:51:11 +02:00
Jakob Stender Guldberg
d64e525aa1 add init to openhands.core to allow import 2025-09-10 12:30:57 +02:00
Jakob Stender Guldberg
45425420fd fix(pytest): adjust count to 3 as we have included readme.md 2025-09-10 11:01:29 +02:00
Jakob Stender Guldberg
700944017a Merge branch 'main' into migrate-to-uv 2025-09-10 09:32:42 +02:00
Tim O'Farrell
b08238c841 Fix for issue where some attributes in pr_data are defined but are null or undefined (#10827) 2025-09-09 21:28:40 +00:00
sp.wack
831084df4c Remove git authentication requirement for secrets in SaaS mode (#10903)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-09 19:50:13 +00:00
sp.wack
eb4dacb577 Fix ruff formatting in enterprise token_manager.py (#10901) 2025-09-09 18:45:45 +00:00
jakob1379
78d5045417 ci: fix runtime tests by installing package in ghcr-build jobs\n\nInstall OpenHands in runtime test jobs to ensure 'openhands.core' is importable.\nThis mirrors unit-test workflow behavior and prevents ModuleNotFoundError.\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-09-09 18:20:53 +00:00
jakob1379
3754a055d0 fix(runtime): include README.md in runtime build context and image to satisfy hatchling readme validation
- Copy README.md alongside pyproject.toml and uv.lock when preparing containers/runtime build folder
- In Dockerfile template, copy README.md if present and add a fallback to create minimal README to avoid hatchling OSError

This fixes CI Docker build failure on ubuntu base image where uv/hatchling raised:
OSError: Readme file does not exist: README.md

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-09 17:09:28 +00:00
jrobles98
8e71459601 Fix typo (#10702) 2025-09-09 12:39:58 -04:00
Tim O'Farrell
fc29815aa0 Value logged as error should be info (#10831) 2025-09-09 08:48:29 -06:00
mamoodi
a809d74b7d Release 0.56.0 (#10876) 2025-09-09 10:30:43 -04:00
Ryan H. Tran
b090d097ed Fix Docker build error 'groupadd: GID 1000 already exists' (#10888) 2025-09-09 21:50:23 +08:00
Jakob Stender Guldberg
f068f084bb no we don't 2025-09-09 14:51:05 +02:00
Jakob Stender Guldberg
d5b3ff0f1c we need readme 2025-09-09 14:45:52 +02:00
Jakob Stender Guldberg
77b534aefe build: split runtime template into logical sections 2025-09-09 13:33:09 +02:00
Jakob Stender Guldberg
db38484aec fix: d not expose os 2025-09-09 13:09:00 +02:00
Jakob Stender Guldberg
c43970835b fix: corrected calling to runtime_build 2025-09-09 13:08:08 +02:00
Jakob Stender Guldberg
6075f0816d fix: install as editable 2025-09-09 11:48:46 +02:00
Jakob Stender Guldberg
729ddfc342 fix: use importlib to get version 2025-09-09 11:03:02 +02:00
Jakob Stender Guldberg
b91af53ac4 fix: simple venv setup 2025-09-09 10:59:37 +02:00
Jakob Stender Guldberg
57ee0e815f chore: lint 2025-09-09 10:21:24 +02:00
Jakob Stender Guldberg
aad747e48f fix: removed poetry references and files 2025-09-09 10:15:09 +02:00
Jakob Stender Guldberg
787111b291 Merge branch 'main' into migrate-to-uv 2025-09-09 10:14:38 +02:00
Graham Neubig
79f32a34a0 Fix SANDBOX_VOLUMES format in headless mode documentation (#10887)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-08 14:17:20 -04:00
Ashwin Kumar B V
805bc5608e Update deprecated dependencies: google-genai and yanked ddtrace (#10866)
Co-authored-by: enyst <engel.nyst@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-08 10:04:16 -05:00
Ray Myers
61e1957cee chore - Make enterprise preview work when labeled after the fact (#10862) 2025-09-08 09:54:51 -05:00
Jakob Stender Guldberg
fa4905a5ed test: fix some issues 2025-09-08 15:48:22 +02:00
Jakob Stender Guldberg
e9c52ac70c test: ensure consistent newlines on linux 2025-09-08 14:57:37 +02:00
Jakob Stender Guldberg
2c7f145adb test: switch to printf for portable multiline bash tests 2025-09-08 14:53:31 +02:00
Joe Axe
a25826a5f9 fix: resolve empty API keys to None and add Bedrock model support (#10573) 2025-09-08 14:45:10 +02:00
Jakob Stender Guldberg
77cd640781 chore: lint 2025-09-08 14:25:16 +02:00
Jakob Stender Guldberg
714516575f chore: lint 2025-09-08 14:21:48 +02:00
Jakob Stender Guldberg
a2283721cd chore: lint 2025-09-08 14:19:23 +02:00
Jakob Stender Guldberg
be8fcda609 let uv handle python 2025-09-08 13:58:00 +02:00
Jakob Stender Guldberg
9109f2f8ec chore: update CI workflow, pre-commit, pyproject, and runtime Dockerfile 2025-09-08 13:46:38 +02:00
Jakob Stender Guldberg
093170c123 ci: fixed enterprise tests 2025-09-08 13:13:51 +02:00
Jakob Stender Guldberg
b51557316b fix: add uv validation to pyproject.toml validator 2025-09-08 12:41:31 +02:00
Jakob Stender Guldberg
e1ca976cf7 build: corrected license 2025-09-08 11:47:46 +02:00
Jakob Stender Gulberg
6b6d87be33 Merge branch 'main' into migrate-to-uv 2025-09-08 11:36:51 +02:00
Jakob Stender Guldberg
49b75588a4 refactor: migrated enterprise to uv too 2025-09-08 11:34:43 +02:00
Jakob Stender Guldberg
1900d565e0 chore: deleting poetry.lock after merge 2025-09-08 11:23:25 +02:00
Jakob Stender Guldberg
11fb3271ec Merge branch 'main' into migrate-to-uv 2025-09-08 11:23:10 +02:00
Ryan H. Tran
df9320f8ab Implement model routing support (#9738)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-08 16:19:34 +07:00
Jakob Stender Guldberg
3bad814236 fix: reverted to original method 2025-09-08 10:25:34 +02:00
Boxuan Li
af0ab5a9f2 Fix working_dir bug in local runtime (#10801)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-07 23:44:55 -07:00
Ruilin Zhou
9960d11d08 feat(runtime): upgrade E2B runtime to v2.0 with full implementation (#10832) 2025-09-08 06:32:08 +02:00
mamoodi
d5d5e265f8 Fix issue #10729: Add x-ai/grok-code-fast-1 to MODELS_WITHOUT_STOP_WORDS (#10867)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-09-08 05:30:45 +02:00
Xingyao Wang
989a4e662b feat: integrate with unified docs repository (#10830)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-06 16:10:21 +02:00
Jakob Stender Guldberg
5aff692533 fix: indentation error 2025-09-06 10:35:35 +02:00
Jakob Stender Guldberg
f119599be3 fix: resolve Playwright path at runtime and refresh after install 2025-09-06 07:48:11 +02:00
骆艺轩
ecfbae2285 refactor: Tweak labels prompt (#10523) (#10757) 2025-09-06 03:17:44 +02:00
Tim O'Farrell
c9cf351697 Added type hints for experiment manager (#10851)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2025-09-05 12:14:16 -06:00
Tim O'Farrell
aca568cfbe More Type Safety (#10848) 2025-09-05 11:34:43 -06:00
dependabot[bot]
3366ad9de7 chore(deps): bump the version-all group in /frontend with 7 updates (#10844)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-05 19:10:32 +04:00
Jakob Stender Guldberg
76f433f8a9 fix: assume lock exists 2025-09-05 16:52:53 +02:00
Jakob Stender Guldberg
51ac6b50f3 build: fix uv installation in dockerfile 2025-09-05 16:32:22 +02:00
Jakob Stender Guldberg
e148d7387a build: copy uv.lock too 2025-09-05 16:16:07 +02:00
Jakob Stender Guldberg
6371f24a0d fix(runtime): revert of unneeded removal 2025-09-05 16:12:06 +02:00
Jakob Stender Guldberg
5129ae3e35 test: revert overengineered step 2025-09-05 16:09:06 +02:00
Jakob Stender Guldberg
0cb16c077c ci: reintroduced uv lock for minimal change 2025-09-05 15:59:42 +02:00
Ankit Kumar Yadav
f442e07b33 docs: replaced slack invite links with dub.sh link (fixes #10768) (#10779) 2025-09-05 08:57:49 -04:00
Jakob Stender Guldberg
ef47624d2a refactor: simplify Playwright install flow and fix TZ default check 2025-09-05 14:21:40 +02:00
Jakob Stender Guldberg
2ec0f56f37 ci: correct uv installation in docker 2025-09-05 13:30:18 +02:00
Jakob Stender Guldberg
2f0a9fb2d5 docs: simplified python setup 2025-09-05 13:22:23 +02:00
Jakob Stender Guldberg
48b6749a33 Merge branch 'migrate-to-uv' of https://github.com/jakob1379/OpenHands into migrate-to-uv 2025-09-05 12:43:58 +02:00
Jakob Stender Guldberg
ed7a858bee chore: switch to uv run in CI/pre-commit and bump openhands-ai 2025-09-05 12:42:44 +02:00
Jakob Stender Guldberg
f6eff72e6d ci: use uv run instead of source .venv... 2025-09-05 11:56:30 +02:00
Jakob Stender Guldberg
b6ce317e5d ci: use uv run instead of source .venv... 2025-09-05 11:55:51 +02:00
Jakob Stender Gulberg
35f7ad922e Merge branch 'main' into migrate-to-uv 2025-09-05 11:49:50 +02:00
Jakob Stender Guldberg
5df43fc062 ci: switch uv setup to cached-uv and use uv.run in workflows 2025-09-05 11:38:16 +02:00
Jakob Stender Guldberg
5a71a3db68 build: dynamically get playwright path 2025-09-05 10:15:57 +02:00
Jakob Stender Guldberg
18b2b2d90e chore: add tomlkit to dependencies 2025-09-05 09:23:41 +02:00
Jakob Stender Guldberg
9bc5b6c015 refactor: switch CI to cached uv and streamline Playwright setup 2025-09-05 09:22:39 +02:00
Jakob Stender Guldberg
be454e9f89 build: remove poetry.lock in favor of uv.lock 2025-09-05 09:21:36 +02:00
Ray Myers
fdf8b21b84 Update enterprise readme (#10826) 2025-09-04 17:20:06 -05:00
Engel Nyst
93e843a06b Revert "feat(agent): add security-related items in system prompt to defense against data exfiltration" (#10822) 2025-09-05 05:07:30 +08:00
Ray Myers
e37f7b0e0f Enterprise code and docker build (#10770) 2025-09-04 15:44:54 -04:00
Haowei Lin
bd8b1bfa25 Add a new benchmark: AlgoTune (#10724)
Co-authored-by: linhaowei <linhaowei@wizardquant.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-09-04 18:08:50 +00:00
dependabot[bot]
a4f11006f6 chore(deps-dev): bump @tanstack/eslint-plugin-query from 5.83.1 to 5.86.0 in /frontend in the eslint group (#10817)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 15:51:28 +00:00
sp.wack
c6950946bb Add refetchInterval to useActiveHost hook for automatic host status updates (#10815)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-04 14:59:16 +00:00
Engel Nyst
81d6341f9d chore(server): rename Session to WebSession (#10565) 2025-09-04 16:49:54 +02:00
dependabot[bot]
55a6bbd9a4 chore(deps): bump the version-all group across 1 directory with 8 updates (#10799)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 17:08:42 +04:00
Zacharias Fisches
20e5c40969 Fix swe-bench run_infer.py config parsing from config.toml (#10792) 2025-09-04 20:10:08 +08:00
Xingyao Wang
3e8dc41bdf Add microagent for fixing E501 line too long errors (#10796)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-04 19:48:08 +08:00
Jakob Stender Guldberg
92e7061047 fix: consolidate pyproject.toml duplicates and restore project sections 2025-09-04 12:26:35 +02:00
Jakob Stender Guldberg
e8ac83519e test: remove Poetry fallback; rely on PEP 621 project version (uv) 2025-09-04 10:32:38 +02:00
Jakob Stender Guldberg
05a8bf964d refactor: read pyproject version from project first; fallback to poetry 2025-09-04 10:31:45 +02:00
Jakob Stender Guldberg
bfe59c171e test: replace Poetry with uv in runtime build tests 2025-09-04 10:29:46 +02:00
Jakob Stender Guldberg
4d5a678d52 fix: replace poetry path with uv in stress test 2025-09-04 10:19:42 +02:00
Jakob Stender Guldberg
5ca40451a7 test: replace poetry run pytest with uv run pytest in tests 2025-09-04 10:18:46 +02:00
Jakob Stender Guldberg
091051dd1d chore: replace poetry.lock with uv.lock in pyproject.toml 2025-09-04 10:03:26 +02:00
Jakob Stender Guldberg
7023dd7ef3 refactor: migrate pyproject.toml to PEP 621 with hatchling 2025-09-04 10:01:56 +02:00
Jakob Stender Guldberg
2acee6f85d build: update build script description to package build (uv) 2025-09-04 09:56:38 +02:00
Jakob Stender Guldberg
3b0fd96915 refactor: migrate Poetry usage to uv in Dockerfile.j2 2025-09-04 09:53:30 +02:00
Jakob Stender Guldberg
ee14fa9a37 refactor: migrate Poetry usage to uv in Dockerfile templates 2025-09-04 09:51:17 +02:00
Jakob Stender Guldberg
254a2db53a feat: migrate to uv.lock across build, hash, and templates 2025-09-04 09:30:43 +02:00
Jakob Stender Guldberg
6db35febc5 refactor: migrate Poetry usage to uv in build and templates 2025-09-04 09:28:01 +02:00
Jakob Stender Guldberg
872d39663a test: update test to reflect uv path instead of poetry in terminal test 2025-09-04 09:23:05 +02:00
Jakob Stender Guldberg
9083e439cc ci: update e2e-tests workflow to uv usage 2025-09-04 09:20:06 +02:00
Jakob Stender Guldberg
cab2dce53f ci: migrate Poetry usage to uv across CI, scripts, and docs 2025-09-04 09:16:06 +02:00
Jakob Stender Guldberg
4398f362a2 chore: migrate Poetry usage to uv in devcontainer 2025-09-04 09:07:08 +02:00
Jakob Stender Guldberg
a73901957e refactor: migrate poetry usage to uv in runloop start command 2025-09-03 18:41:16 +02:00
Jakob Stender Guldberg
775ffe2fc3 chore: switch frontend pre-commit to .venv/bin/pre-commit 2025-09-03 18:35:40 +02:00
Jakob Stender Guldberg
1b0f0ed0fa fix: replace poetry with uv in visual_swe_bench/run_infer.sh 2025-09-03 18:28:16 +02:00
Jakob Stender Guldberg
c3e0178bca refactor: migrate poetry to uv in runloop 2025-09-03 18:24:55 +02:00
Jakob Stender Guldberg
f6de825706 test: update ps1 metadata to reflect uv virtualenv path 2025-09-03 18:23:51 +02:00
Jakob Stender Guldberg
037401069a test: replace poetry commands with uv equivalents in test message 2025-09-03 18:21:56 +02:00
Jakob Stender Guldberg
8f808771f2 chore: update tests to uv commands 2025-09-03 18:20:31 +02:00
Jakob Stender Guldberg
5e53c2598d docs: replace poetry run pytest with uv run pytest in unit README 2025-09-03 18:17:28 +02:00
Jakob Stender Guldberg
9aeabc4dd2 refactor: switch py_interpreter_path from poetry to uv in tests 2025-09-03 18:16:49 +02:00
Jakob Stender Guldberg
21402a75aa refactor: switch test runtime py_interpreter_path from poetry to uv 2025-09-03 18:16:15 +02:00
Jakob Stender Guldberg
1c00d673b5 test: update expected Python interpreter path from poetry to uv in ipython tests 2025-09-03 18:14:28 +02:00
Jakob Stender Guldberg
da7ec8d06f chore: migrate Poetry usage to uv in test_browsing.py comments 2025-09-03 18:13:05 +02:00
Jakob Stender Guldberg
025b0ad0d6 test: migrate poetry usage to uv in mem check 2025-09-03 18:12:06 +02:00
Jakob Stender Guldberg
94591ab602 docs: replace poetry commands with uv in runtime README 2025-09-03 17:55:29 +02:00
Jakob Stender Guldberg
372aedc4e1 docs: switch Poetry to uv in tests/e2e/README.md 2025-09-03 17:54:51 +02:00
Jakob Stender Guldberg
7e5b6d8949 docs: replace poetry with uv in server README 2025-09-03 17:51:16 +02:00
Jakob Stender Guldberg
252cc1c79a refactor: migrate Poetry usage to uv in Dockerfile templates 2025-09-03 16:31:10 +02:00
Jakob Stender Guldberg
09247a079c refactor: migrate Poetry usage to uv in Jupyter plugin 2025-09-03 16:26:42 +02:00
Jakob Stender Guldberg
b9a3434052 docs: migrate pytest invocation from poetry to uv in all-hands prompt 2025-09-03 16:25:21 +02:00
Jakob Stender Guldberg
166441eccc docs: switch poetry to uv in repo_instructions prompt 2025-09-03 16:24:33 +02:00
Jakob Stender Guldberg
fa58c931fa docs: replace poetry command with uv in resolver README 2025-09-03 16:24:04 +02:00
Jakob Stender Guldberg
6e012eb8fa docs: replace poetry run with uv run in README code block 2025-09-03 16:21:53 +02:00
Jakob Stender Guldberg
583bd87753 docs: replace poetry run pytest with uv run pytest in add_repo_inst.md 2025-09-03 16:21:15 +02:00
Jakob Stender Guldberg
a92248d1d1 fix: replace poetry interpreter path with uv in parse-terminal-output.ts 2025-09-03 16:20:21 +02:00
Jakob Stender Guldberg
02b90a0b90 test: update parse-terminal-output tests to use uv interpreter path 2025-09-03 16:19:31 +02:00
Jakob Stender Guldberg
f12f359e9a chore: swap poetry for uv in version_control.sh 2025-09-03 16:17:23 +02:00
Jakob Stender Guldberg
7c61795cd7 docs: replace poetry run with uv run in visualwebarena README 2025-09-03 16:16:47 +02:00
Jakob Stender Guldberg
032b2887e7 refactor: replace poetry with uv in run_infer.sh 2025-09-03 16:07:11 +02:00
Jakob Stender Guldberg
3cd8c78817 chore: replace poetry with uv in webarena run_infer script 2025-09-03 16:03:47 +02:00
Jakob Stender Guldberg
6365c8e61f docs: replace poetry run with uv run in webarena README 2025-09-03 16:01:01 +02:00
Jakob Stender Guldberg
ac9d1c9638 chore: replace poetry run with uv in run_infer.sh 2025-09-03 15:59:16 +02:00
Jakob Stender Guldberg
4c326fc42c chore: migrate poetry usage to uv in eval_infer.sh 2025-09-03 15:52:18 +02:00
Jakob Stender Guldberg
1cb18bc8c0 refactor: migrate poetry usage to uv in run_infer.sh 2025-09-03 15:48:26 +02:00
Jakob Stender Guldberg
ef26317f70 chore: update usage message from poetry to uv 2025-09-03 15:46:53 +02:00
Jakob Stender Guldberg
e593fc1a7d refactor: migrate poetry to uv in run_infer.sh 2025-09-03 15:46:27 +02:00
Jakob Stender Guldberg
df87350968 chore: migrate poetry usage to uv in run_infer.sh 2025-09-03 15:45:44 +02:00
Jakob Stender Guldberg
ba7543e880 chore: migrate poetry usage to uv in testgeneval eval scripts 2025-09-03 15:43:40 +02:00
Jakob Stender Guldberg
fd4e95c972 chore: replace poetry with uv in convert_oh_folder_to_swebench_submission.sh 2025-09-03 15:42:10 +02:00
Jakob Stender Guldberg
0694aa9c6d docs: replace poetry install with uv sync for testgeneval 2025-09-03 15:41:23 +02:00
Jakob Stender Guldberg
681f999351 chore: migrate Poetry invocations to uv in swe_bench scripts 2025-09-03 15:34:58 +02:00
Jakob Stender Guldberg
aa3d7d948f chore: migrate poetry usage to uv across scripts 2025-09-03 15:30:08 +02:00
Jakob Stender Guldberg
3e8ca863e8 refactor: migrate poetry to uv run across evaluation benchmarks scripts 2025-09-03 15:17:37 +02:00
Jakob Stender Guldberg
5a41d7932a refactor: migrate benchmark scripts from poetry to uv 2025-09-03 15:08:35 +02:00
Jakob Stender Guldberg
6f669bbba5 ci: migrate poetry to uv across evaluation benchmarks and ci scripts 2025-09-03 15:06:01 +02:00
Jakob Stender Guldberg
6eff35a83b feat: migrate poetry to uv in benchmark run scripts 2025-09-03 15:04:10 +02:00
Jakob Stender Guldberg
2ce6b73d1c chore: migrate poetry to uv in selected files 2025-09-03 15:02:58 +02:00
Jakob Stender Guldberg
50c8e9b74e refactor: migrate poetry to uv in benchmarks 2025-09-03 15:01:05 +02:00
Jakob Stender Guldberg
35ff463d73 docs: migrate poetry to uv commands across how-to and windows docs 2025-09-03 14:59:14 +02:00
Jakob Stender Guldberg
94ab002763 docs: replace poetry with uv in usage and runtime docs 2025-09-03 14:56:38 +02:00
Jakob Stender Guldberg
3637a5476a refactor: migrate from Poetry to uv in Dockerfiles and README 2025-09-03 14:54:04 +02:00
Jakob Stender Guldberg
3cb21ce22f build: drop Poetry checks; switch to uv; update docs/scripts 2025-09-03 14:51:50 +02:00
Jakob Stender Guldberg
c6d052ec8c chore: ignore .aider* files and remove MIGRATION.md 2025-09-03 14:48:59 +02:00
Jakob Stender Guldberg
4bf02d1f09 refactor: migrate tooling from Poetry to uv across repo 2025-09-03 14:47:38 +02:00
Jakob Stender Guldberg
e9dde2fc1d docs: update MIGRATION.md with Poetry usage audit results 2025-09-03 12:46:26 +02:00
Jakob Stender Guldberg
f514eaa041 docs: add MIGRATION.md with Poetry usage audit 2025-09-03 12:44:24 +02:00
Jakob Stender Guldberg
9b9055521c chore: add migration.md task file 2025-09-03 12:41:44 +02:00
Hiep Le
49d37119a9 chore(frontend): remove feature flag (microagent management) (#10769) 2025-09-02 19:46:09 +00:00
Jamie Chicago
cfd416c29f feat: update welcome-good-first-issue.yml (#10766) 2025-09-02 19:41:58 +00:00
Ray Myers
c052dd7da5 chore - Update license for enterprise folder (#10761) 2025-09-02 18:48:45 +00:00
Ryan H. Tran
3f77b8229a Add support for AGENTS.md files in microagent system (#10528)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-03 02:18:38 +08:00
Tim O'Farrell
8d13c9f328 UI for determining if llm options are enabled (#10665)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-02 12:09:55 -06:00
mamoodi
f46b112f17 Add more troubleshooting for linux (#10704) 2025-09-02 14:02:16 -04:00
mamoodi
44dc7f9e9b Release 0.55.0 (#10657) 2025-09-02 13:49:02 -04:00
Hiep Le
00eaa7a6e1 refactor(frontend): remove the branch dropdown from the learn this repo modal (microagent management) (#10755) 2025-09-02 22:34:00 +07:00
Hiep Le
9f1d6963b8 feat(frontend): support pagination when loading repositories (microagent management) (#10708) 2025-09-02 17:03:58 +04:00
Rohit Malhotra
f61fa93596 Fix fragile URL parsing in Bitbucket service search_repositories method (#10733)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-01 02:59:24 -04:00
Rohit Malhotra
3e87c08631 refactor: introduce HTTPClient protocol for git service integrations (#10731)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-01 01:44:31 -04:00
Rohit Malhotra
21f3ef540f refactor: Apply GitHub mixins pattern to BitBucket service (#10728)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-31 16:09:41 -04:00
Rohit Malhotra
61a93d010c Refactor GitLab service into modular mixins pattern (#10727)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-31 19:10:40 +00:00
Rohit Malhotra
9d6afa09b6 Fix GraphQL URL configuration for GitHub Enterprise Server (#10725)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-30 18:06:00 -04:00
Rohit Malhotra
c648b6f74f Refactor: Modularize GitHubService into feature mixins (#10492)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-29 23:45:15 -04:00
sp.wack
c0fa41da65 fix: auto-load repositories when insufficient content in dropdown (#10697)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-08-29 21:17:27 +00:00
Rohit Malhotra
6eb32e9ae4 Fix: Add method to merge conversation stats (#10667)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-29 20:15:44 +00:00
Rohit Malhotra
6a544d4274 (Hotfix): Branch pagination for GitLab (#10710)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-29 20:15:15 +00:00
Calvin Smith
4aada82b75 fix: Linking condensation and task tracking prompts (#10656)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-08-29 09:18:31 -06:00
Ryan H. Tran
ab2da611f5 fix: validate task_list schema for task tracker (#10624) 2025-08-29 10:57:50 +00:00
Rohit Malhotra
e47bcf31e4 [Bug, GitLab]: fix missing context in cloud resolver (#10509)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-29 02:38:03 -04:00
mamoodi
83b9262379 Add troubleshooting guide for linux timeout issue (#10685) 2025-08-29 05:52:36 +02:00
Rohit Malhotra
edc95141f7 Implement branch pagination for repository selection and improve UI async dropdown behaviour (#10588)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-08-29 03:38:42 +00:00
Hiep Le
5b35203253 refactor: remove branch dropdown, update title, fix pr_number issue (microagent management) (#10691) 2025-08-29 00:24:48 +04:00
Rohit Malhotra
7e3eabe777 (Hotfix): ConversationStats metrics loss for unregistered services (#10676)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-28 14:15:20 -04:00
dependabot[bot]
23713bfe8c chore(deps): bump the version-all group in /frontend with 5 updates (#10686)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-28 14:53:14 +00:00
Ryan H. Tran
81829289ab Add support for passing list of Message into LLM completion (#10671) 2025-08-28 21:22:28 +08:00
Ray Myers
9709431874 fix: cli dedupe TaskTrackingAction thoughts by using display_thought_if_new (#10660)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-28 21:20:39 +08:00
dependabot[bot]
0e9906f41e chore(deps): bump posthog-js from 1.260.3 to 1.261.0 in /frontend in the version-all group (#10658)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-28 16:15:31 +04:00
1262 changed files with 104678 additions and 31347 deletions

View File

@@ -8,7 +8,7 @@
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
"ghcr.io/devcontainers-extra/features/poetry:2": {},
"ghcr.io/astral-sh/devcontainer-features/uv:1": {},
"ghcr.io/devcontainers/features/node:1": {},
},
"postCreateCommand": ".devcontainer/setup.sh",

23
.github/workflows/dispatch-to-docs.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Dispatch to docs repo
on:
push:
branches: [main]
paths:
- 'docs/**'
workflow_dispatch:
jobs:
dispatch:
runs-on: ubuntu-latest
strategy:
matrix:
repo: ["All-Hands-AI/docs"]
steps:
- name: Push to docs repo
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
repository: ${{ matrix.repo }}
event-type: update
client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "module": "openhands", "branch": "main"}'

View File

@@ -21,16 +21,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4
with:
poetry-version: 2.1.3
- uses: hynek/setup-cached-uv@v2
name: Setup uv
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'poetry'
- name: Install system dependencies
run: |
@@ -69,9 +66,8 @@ jobs:
ENABLE_BROWSER: true
INSTALL_PLAYWRIGHT: 1
run: |
# Fix poetry.lock file if needed
echo "Fixing poetry.lock file if needed..."
poetry lock
# Using uv; no lockfile fix needed
uv lock
# Build OpenHands using make build
echo "Running make build..."
@@ -79,11 +75,11 @@ jobs:
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
echo "Installing Chromium Headless Shell for Playwright..."
poetry run playwright install chromium-headless-shell
uv run playwright install chromium-headless-shell
# Verify Playwright browsers are installed (for e2e tests only)
echo "Verifying Playwright browsers installation for e2e tests..."
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
BROWSER_CHECK=$(uv run python tests/e2e/check_playwright.py 2>/dev/null)
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
echo "ERROR: Chromium browser not found or not working for e2e tests"
@@ -183,7 +179,7 @@ jobs:
# Run the tests with detailed output
cd tests/e2e
poetry run python -m pytest \
uv run python -m pytest \
test_settings.py::test_github_token_configuration \
test_conversation.py::test_conversation_start \
test_browsing_catchphrase.py::test_browsing_catchphrase \

View File

@@ -0,0 +1,29 @@
# Feature branch preview for enterprise code
name: Enterprise Preview
# Run on PRs labeled
on:
pull_request:
types: [labeled]
# Match ghcr-build.yml, but don't interrupt it.
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: false
jobs:
# This must happen for the PR Docker workflow when the label is present,
# and also if it's added after the fact. Thus, it exists in both places.
enterprise-preview:
name: Enterprise preview
if: github.event.label.name == 'deploy'
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
# This should match the version in ghcr-build.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/All-Hands-AI/deploy/actions/workflows/deploy.yaml/dispatches

View File

@@ -10,14 +10,14 @@ on:
branches:
- main
tags:
- '*'
- "*"
pull_request:
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual trigger'
description: "Reason for manual trigger"
required: true
default: ''
default: ""
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
@@ -46,6 +46,7 @@ jobs:
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ghcr.io/all-hands-ai/python-nodejs:python3.13-nodejs22-trixie", tag: "trixie" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
@@ -115,17 +116,17 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- uses: hynek/setup-cached-uv@v2
name: Setup uv
- name: Install Python dependencies using uv
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Install OpenHands
run: uv pip install -e .
- name: Create source distribution and Dockerfile
run: poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
run: uv run -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
@@ -136,12 +137,13 @@ jobs:
if: github.event.pull_request.head.repo.fork != true
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
echo "DOCKER_PLATFORM=$(echo "$DOCKER_BUILD_JSON" | jq -r '.platform')" >> $GITHUB_ENV
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
- name: Build and push runtime image ${{ matrix.base_image.image }}
if: github.event.pull_request.head.repo.fork != true
uses: useblacksmith/build-push-action@v1
@@ -152,6 +154,7 @@ jobs:
build-args: ${{ env.DOCKER_BUILD_ARGS }}
context: containers/runtime
provenance: false
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
@@ -166,6 +169,92 @@ jobs:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
ghcr_build_enterprise:
name: Push Enterprise Image
runs-on: blacksmith-8vcpu-ubuntu-2204
permissions:
contents: read
packages: write
needs: [define-matrix, ghcr_build_app]
# Do not build enterprise in forks
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# Set up Docker Buildx for better performance
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/all-hands-ai/enterprise-server
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha
type=sha,format=long
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
flavor: |
latest=auto
prefix=
suffix=
env:
DOCKER_METADATA_PR_HEAD_SHA: true
- name: Determine app image tag
shell: bash
run: |
# Duplicated with build.sh
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
with:
context: .
file: enterprise/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
OPENHANDS_VERSION=${{ env.OPENHANDS_DOCKER_TAG }}
platforms: linux/amd64
# Add build provenance
provenance: true
# Add build attestations for better security
sbom: true
enterprise-preview:
name: Enterprise preview
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_enterprise]
steps:
# This should match the version in enterprise-preview.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/All-Hands-AI/deploy/actions/workflows/deploy.yaml/dispatches
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
@@ -197,35 +286,30 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
- uses: hynek/setup-cached-uv@v2
name: Setup uv
- name: Install Python dependencies using uv
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Install OpenHands
run: uv pip install -e .
- name: Run docker runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
# Setting RUN_AS_OPENHANDS to false means use root.
# That should mean SANDBOX_USER_ID is ignored but some tests do not check for RUN_AS_OPENHANDS.
TEST_RUNTIME=docker \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
PYTHONPATH=".:$PYTHONPATH" \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
SANDBOX_USER_ID=$(id -u) \
TEST_IN_CI=true \
TEST_RUNTIME=docker \
uv run --group test pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
@@ -259,32 +343,25 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
- uses: hynek/setup-cached-uv@v2
name: Setup uv
- name: Install Python dependencies using uv
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Install OpenHands
run: uv pip install -e .
- name: Run runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
TEST_RUNTIME=docker \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
PYTHONPATH=".:$PYTHONPATH" \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
SANDBOX_USER_ID=$(id -u) \
TEST_IN_CI=true \
TEST_RUNTIME=docker \
uv run --group test pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"

View File

@@ -31,14 +31,13 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install poetry via pipx
run: pipx install poetry
- uses: hynek/setup-cached-uv@v2
name: Setup uv
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Setup Node.js
uses: useblacksmith/setup-node@v5
@@ -53,8 +52,8 @@ jobs:
comment: |
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime,evaluation
- name: Install Python dependencies using uv
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Configure config.toml for testing with Haiku
env:
@@ -76,7 +75,7 @@ jobs:
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'haiku_run'
uv run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'haiku_run'
# get integration tests report
REPORT_FILE_HAIKU=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/*haiku*_maxiter_10_N* -name "report.md" -type f | head -n 1)
@@ -106,7 +105,7 @@ jobs:
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'deepseek_run'
uv run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'deepseek_run'
# get integration tests report
REPORT_FILE_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/deepseek*_maxiter_10_N* -name "report.md" -type f | head -n 1)
@@ -137,7 +136,7 @@ jobs:
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
uv run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
# Find and export the visual browsing agent test results
REPORT_FILE_VISUALBROWSING_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/VisualBrowsingAgent/deepseek*_maxiter_15_N* -name "report.md" -type f | head -n 1)

View File

@@ -55,6 +55,24 @@ jobs:
- name: Run pre-commit hooks
run: pre-commit run --all-files --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
lint-enterprise-python:
name: Lint enterprise python
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up python
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
- name: Install pre-commit
run: pip install pre-commit==4.2.0
- name: Run pre-commit hooks
working-directory: ./enterprise
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
# Check version consistency across documentation
check-version-consistency:
name: Check version consistency

70
.github/workflows/mdx-lint.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
# Workflow that checks MDX format in docs/ folder
name: MDX Lint
# Run on pushes to main and on pull requests that modify docs/ files
on:
push:
branches:
- main
paths:
- 'docs/**/*.mdx'
pull_request:
paths:
- 'docs/**/*.mdx'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
mdx-lint:
name: Lint MDX files
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
with:
node-version: 22
- name: Install MDX dependencies
run: |
npm install @mdx-js/mdx@3 glob@10
- name: Validate MDX files
run: |
node -e "
const {compile} = require('@mdx-js/mdx');
const fs = require('fs');
const path = require('path');
const glob = require('glob');
async function validateMDXFiles() {
const files = glob.sync('docs/**/*.mdx');
console.log('Found', files.length, 'MDX files to validate');
let hasErrors = false;
for (const file of files) {
try {
const content = fs.readFileSync(file, 'utf8');
await compile(content);
console.log('✅ MDX parsing successful for', file);
} catch (err) {
console.error('❌ MDX parsing failed for', file, ':', err.message);
hasErrors = true;
}
}
if (hasErrors) {
console.error('\\n❌ Some MDX files have parsing errors. Please fix them before merging.');
process.exit(1);
} else {
console.log('\\n✅ All MDX files are valid!');
}
}
validateMDXFiles();
"

View File

@@ -19,12 +19,16 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
INSTALL_DOCKER: '0' # Set to '0' to skip Docker installation
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
matrix:
python-version: ['3.12']
python-version: ["3.12"]
permissions:
# For coverage comment and python-coverage-comment-action branch
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
@@ -36,50 +40,97 @@ jobs:
uses: useblacksmith/setup-node@v5
with:
node-version: '22.x'
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'poetry'
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime
- uses: hynek/setup-cached-uv@v2
name: Setup uv
- name: Install Python dependencies using uv
run: uv sync --python ${{ matrix.python-version }} --group dev --group test --group runtime
- name: Build Environment
run: make build
- name: Run Unit Tests
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv ./tests/unit
run: PYTHONPATH=".:$PYTHONPATH" uv run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli uv run pytest -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v4
with:
name: coverage-openhands
path: |
.coverage.${{ matrix.python_version }}
.coverage.runtime.${{ matrix.python_version }}
include-hidden-files: true
# Run specific Windows python tests
test-on-windows:
name: Python Tests on Windows
runs-on: windows-latest
strategy:
matrix:
python-version: ['3.12']
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- name: Install pipx
run: pip install pipx
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'poetry'
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime
- uses: hynek/setup-cached-uv@v2
name: Setup uv
- name: Install Python dependencies using uv
run: uv sync --group dev --group test --group runtime --python ${{ matrix.python-version }}
- name: Run Windows unit tests
run: poetry run pytest -svv tests/unit/runtime/utils/test_windows_bash.py
run: uv run pytest -svv tests/unit/runtime/utils/test_windows_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
DEBUG: "1"
- name: Run Windows runtime tests with LocalRuntime
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
run: $env:TEST_RUNTIME="local"; .venv\Scripts\pytest -svv tests/runtime/test_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
TEST_RUNTIME: local
DEBUG: "1"
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- uses: hynek/setup-cached-uv@v2
name: Setup uv
- name: Initialize python venv
run: uv sync -p ${{ matrix.python-version }} --group dev --group test
working-directory: ./enterprise
- name: Run Unit Tests
# Use base working directory for coverage paths to line up.
run: PYTHONPATH=".:$PYTHONPATH" uv run --from=enterprise pytest --forked -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v4
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ matrix.python_version }}"
include-hidden-files: true
coverage-comment:
name: Coverage Comment
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: [test-on-linux, test-enterprise]
permissions:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v5
id: download
with:
pattern: coverage-*
merge-multiple: true
- name: Coverage comment
id: coverage_comment
uses: py-cov-action/python-coverage-comment-action@v3
with:
GITHUB_TOKEN: ${{ github.token }}
MERGE_COVERAGE_FILES: true

View File

@@ -1,7 +1,7 @@
# Publishes the OpenHands PyPi package
name: Publish PyPi Package
# Triggered manually
on:
workflow_dispatch:
inputs:
@@ -9,6 +9,9 @@ on:
description: 'Reason for manual trigger'
required: true
default: ''
push:
tags:
- "*"
jobs:
release:
@@ -18,14 +21,9 @@ jobs:
- uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
- name: Install Poetry
uses: snok/install-poetry@v1.4.1
with:
virtualenvs-in-project: true
virtualenvs-path: ~/.virtualenvs
- name: Install Poetry Dependencies
run: poetry install --no-interaction --no-root
- name: Build poetry project
run: ./build.sh
- name: publish
run: poetry publish -u __token__ -p ${{ secrets.PYPI_TOKEN }}
- uses: hynek/setup-cached-uv@v2
name: Setup uv
- name: Build package
run: uv build
- name: Publish package to PyPI
run: uv publish --token ${{ secrets.PYPI_TOKEN }}

View File

@@ -15,8 +15,8 @@ jobs:
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
days-before-stale: 40
exempt-issue-labels: roadmap,backlog
exempt-issue-labels: roadmap,backlog,app-team
close-issue-message: 'This issue was automatically closed due to 50 days of inactivity. We do this to help keep the issues somewhat manageable and focus on active issues.'
close-pr-message: 'This PR was closed because it had no activity for 50 days. If you feel this was closed in error, and you would like to continue the PR, please resubmit or let us know.'
days-before-close: 10
operations-per-run: 150
operations-per-run: 300

View File

@@ -26,6 +26,9 @@ jobs:
validate-vscode-extension:
name: Validate VSCode Extension Build
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: read
issues: write
steps:
- name: Checkout
@@ -77,7 +80,7 @@ jobs:
retention-days: 7
- name: Comment on PR with artifact link
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v7
with:
script: |
@@ -137,6 +140,8 @@ jobs:
release:
name: Create GitHub Release
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: write
needs: validate-vscode-extension
if: startsWith(github.ref, 'refs/tags/ext-v')

View File

@@ -45,6 +45,7 @@ jobs:
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
"Feel free to join our developer community on [Slack](https://all-hands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
"🙌 Happy hacking! 🙌\n\n" +
"<!-- auto-comment:good-first-issue -->"
});

View File

@@ -41,7 +41,7 @@ Backend:
- Located in the `openhands` directory
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
- To test new code, run `uv run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
- Write all tests with pytest
Frontend:
@@ -87,8 +87,6 @@ VSCode Extension:
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.
If you need to add labels when opening a PR, check the existing labels defined on that repository and select from existing ones. Do not invent your own labels.
## Implementation Details
These details may or may not be useful for your current task.

View File

@@ -143,7 +143,7 @@ if [ "$has_frontend_changes" = false ] && [ "$has_backend_changes" = false ]; th
echo "No specific code changes detected. Running basic checks..."
if [ -n "$STAGED_FILES" ]; then
# Run only basic pre-commit hooks for non-code files
poetry run pre-commit run --files $(echo "$STAGED_FILES" | tr '\n' ' ') --hook-stage commit --config ./dev_config/python/.pre-commit-config.yaml
uv run pre-commit run --files $(echo "$STAGED_FILES" | tr '\n' ' ') --hook-stage commit --config ./dev_config/python/.pre-commit-config.yaml
if [ $? -ne 0 ]; then
echo "Basic checks failed. Please fix the issues before committing."
EXIT_CODE=1

View File

@@ -113,19 +113,19 @@ individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
### Slack and Discord Etiquettes
### Slack Etiquettes
These Slack and Discord etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all community members. By following these best practices, we ensure effective communication and collaboration while minimizing disruptions. Lets work together to build a supportive and welcoming community!
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all community members. By following these best practices, we ensure effective communication and collaboration while minimizing disruptions. Lets work together to build a supportive and welcoming community!
- Communicate respectfully and professionally, avoiding sarcasm or harsh language, and remember that tone can be difficult to interpret in text.
- Use threads for specific discussions to keep channels organized and easier to follow.
- Tag others only when their input is critical or urgent, and use @here, @channel or @everyone sparingly to minimize disruptions.
- Be patient, as open-source contributors and maintainers often have other commitments and may need time to respond.
- Post questions or discussions in the most relevant channel (e.g., for [slack - #general](https://openhands-ai.slack.com/archives/C06P5NCGSFP) for general topics, [slack - #questions](https://openhands-ai.slack.com/archives/C06U8UTKSAD) for queries/questions, [discord - #general](https://discord.com/channels/1222935860639563850/1222935861386018885)).
- Post questions or discussions in the most relevant channel (e.g., for [slack - #general](https://openhands-ai.slack.com/archives/C06P5NCGSFP) for general topics, [slack - #questions](https://openhands-ai.slack.com/archives/C06U8UTKSAD) for queries/questions.
- When asking for help or raising issues, include necessary details like links, screenshots, or clear explanations to provide context.
- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the matter is sensitive or private.
- Always adhere to [our standards](https://github.com/All-Hands-AI/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For example, if you're here for discussions about LLMs, mute the channel if its too busy, but set notifications to alert you only when “LLMs” appears in messages. Also for Discord, go to the channel notifications and choose the option that best describes your need.
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For example, if you're here for discussions about LLMs, mute the channel if its too busy, but set notifications to alert you only when “LLMs” appears in messages.
## Attribution

View File

@@ -14,7 +14,7 @@ you can clone the OpenHands project directly.
- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
- [Python](https://www.python.org/downloads/) = 3.12
- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
- [uv](https://github.com/astral-sh/uv) >= 0.4
- OS-specific dependencies:
- Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev`
- WSL: netcat => `sudo apt-get install netcat`
@@ -42,10 +42,10 @@ If you want to develop without system admin/sudo access to upgrade/install `Pyth
curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
bash Miniforge3-$(uname)-$(uname -m).sh
# Install Python 3.12, nodejs, and poetry
# Install Python 3.12, Node.js, and uv
mamba install python=3.12
mamba install conda-forge::nodejs
mamba install conda-forge::poetry
curl -LsSf https://astral.sh/uv/install.sh | sh
```
### 2. Build and Setup The Environment
@@ -146,20 +146,20 @@ To run tests, refer to the following:
#### Unit tests
```bash
poetry run pytest ./tests/unit/test_*.py
uv run -m pytest ./tests/unit/test_*.py
```
### 9. Add or update dependency
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
2. Update the poetry.lock file via `poetry lock --no-update`.
1. Add your dependency in `pyproject.toml`.
2. Update the lock file via `uv lock`.
### 10. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.54-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.58-nikolaik`
## Develop inside Docker container

View File

@@ -1,7 +1,12 @@
The MIT License (MIT)
Portions of this software are licensed as follows:
* All content that resides under the enterprise/ directory is licensed under the license defined in "enterprise/LICENSE".
* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below.
=====================
Copyright © 2023
The MIT License (MIT)
Copyright © 2025
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation

123
Makefile
View File

@@ -1,4 +1,4 @@
SHELL=/usr/bin/env bash
SHELL := /usr/bin/env bash
# Makefile for OpenHands project
# Variables
@@ -13,13 +13,14 @@ CONFIG_FILE = config.toml
PRE_COMMIT_CONFIG_PATH = "./dev_config/python/.pre-commit-config.yaml"
PYTHON_VERSION = 3.12
KIND_CLUSTER_NAME = "local-hands"
PLAYWRIGHT_BIN := $(shell uv run which playwright 2>/dev/null)
# ANSI color codes
GREEN=$(shell tput -Txterm setaf 2)
YELLOW=$(shell tput -Txterm setaf 3)
RED=$(shell tput -Txterm setaf 1)
BLUE=$(shell tput -Txterm setaf 6)
RESET=$(shell tput -Txterm sgr0)
GREEN := $(shell tput -Txterm setaf 2)
YELLOW := $(shell tput -Txterm setaf 3)
RED := $(shell tput -Txterm setaf 1)
BLUE := $(shell tput -Txterm setaf 6)
RESET := $(shell tput -Txterm sgr0)
# Build
build:
@@ -34,13 +35,12 @@ build:
check-dependencies:
@echo "$(YELLOW)Checking dependencies...$(RESET)"
@$(MAKE) -s check-system
@$(MAKE) -s check-python
@$(MAKE) -s check-npm
@$(MAKE) -s check-nodejs
ifeq ($(INSTALL_DOCKER),)
@$(MAKE) -s check-docker
endif
@$(MAKE) -s check-poetry
@$(MAKE) -s check-uv
@$(MAKE) -s check-tmux
@echo "$(GREEN)Dependencies checked successfully.$(RESET)"
@@ -61,15 +61,6 @@ check-system:
exit 1; \
fi
check-python:
@echo "$(YELLOW)Checking Python installation...$(RESET)"
@if command -v python$(PYTHON_VERSION) > /dev/null; then \
echo "$(BLUE)$(shell python$(PYTHON_VERSION) --version) is already installed.$(RESET)"; \
else \
echo "$(RED)Python $(PYTHON_VERSION) is not installed. Please install Python $(PYTHON_VERSION) to continue.$(RESET)"; \
exit 1; \
fi
check-npm:
@echo "$(YELLOW)Checking npm installation...$(RESET)"
@if command -v npm > /dev/null; then \
@@ -104,6 +95,15 @@ check-docker:
exit 1; \
fi
check-uv:
@echo "$(YELLOW)Checking uv installation...$(RESET)"
@if command -v uv > /dev/null; then \
echo "$(BLUE)uv $$(uv --version) is already installed.$(RESET)"; \
else \
echo "$(RED)uv is not installed. Please install uv from https://docs.astral.sh/uv/ to continue.$(RESET)"; \
exit 1; \
fi
check-tmux:
@echo "$(YELLOW)Checking tmux installation...$(RESET)"
@if command -v tmux > /dev/null; then \
@@ -116,59 +116,49 @@ check-tmux:
echo "$(YELLOW)╚════════════════════════════════════════════════════════════════════════════╝$(RESET)"; \
fi
check-poetry:
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
@if command -v poetry > /dev/null; then \
POETRY_VERSION=$(shell poetry --version 2>&1 | sed -E 's/Poetry \(version ([0-9]+\.[0-9]+\.[0-9]+)\)/\1/'); \
IFS='.' read -r -a POETRY_VERSION_ARRAY <<< "$$POETRY_VERSION"; \
if [ $${POETRY_VERSION_ARRAY[0]} -gt 1 ] || ([ $${POETRY_VERSION_ARRAY[0]} -eq 1 ] && [ $${POETRY_VERSION_ARRAY[1]} -ge 8 ]); then \
echo "$(BLUE)$(shell poetry --version) is already installed.$(RESET)"; \
else \
echo "$(RED)Poetry 1.8 or later is required. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
exit 1; \
fi; \
else \
echo "$(RED)Poetry is not installed. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
exit 1; \
fi
install-python-dependencies:
install-python-dependencies: check-uv
@echo "$(GREEN)Installing Python dependencies...$(RESET)"
@if [ -z "${TZ}" ]; then \
echo "Defaulting TZ (timezone) to UTC"; \
export TZ="UTC"; \
fi
poetry env use python$(PYTHON_VERSION)
@uv python pin $(PYTHON_VERSION)
@uv sync --group dev --group test --group runtime
@if [ "$(shell uname)" = "Darwin" ]; then \
echo "$(BLUE)Installing chroma-hnswlib...$(RESET)"; \
export HNSWLIB_NO_NATIVE=1; \
poetry run pip install chroma-hnswlib; \
fi
@if [ -n "${POETRY_GROUP}" ]; then \
echo "Installing only POETRY_GROUP=${POETRY_GROUP}"; \
poetry install --only $${POETRY_GROUP}; \
else \
poetry install --with dev,test,runtime; \
HNSWLIB_NO_NATIVE=1 uv pip install chroma-hnswlib; \
fi
@if [ "${INSTALL_PLAYWRIGHT}" != "false" ] && [ "${INSTALL_PLAYWRIGHT}" != "0" ]; then \
PLAYWRIGHT_BIN=$$(uv run which playwright 2>/dev/null || true); \
NEED_INSTALL=0; \
if [ -f "/etc/manjaro-release" ]; then \
echo "$(BLUE)Detected Manjaro Linux. Installing Playwright dependencies...$(RESET)"; \
poetry run pip install playwright; \
poetry run playwright install chromium; \
if [ -z "$$PLAYWRIGHT_BIN" ] || [ ! -x "$$PLAYWRIGHT_BIN" ]; then \
uv pip install playwright; \
PLAYWRIGHT_BIN=$$(uv run which playwright 2>/dev/null); \
fi; \
"$$PLAYWRIGHT_BIN" install chromium; \
else \
if [ ! -f cache/playwright_chromium_is_installed.txt ]; then \
echo "Running playwright install --with-deps chromium..."; \
poetry run playwright install --with-deps chromium; \
mkdir -p cache; \
touch cache/playwright_chromium_is_installed.txt; \
if [ -n "$$PLAYWRIGHT_BIN" ] && [ -x "$$PLAYWRIGHT_BIN" ]; then \
PLAYWRIGHT_VERSION="$$( "$$PLAYWRIGHT_BIN" --version 2>/dev/null | awk '{print $$2}' || echo unknown )"; \
PLAYWRIGHT_SENTINEL="cache/playwright_chromium_is_installed.$$PLAYWRIGHT_VERSION.txt"; \
if [ ! -f "$$PLAYWRIGHT_SENTINEL" ]; then NEED_INSTALL=1; fi; \
else \
echo "Setup already done. Skipping playwright installation."; \
fi \
fi \
NEED_INSTALL=1; \
fi; \
if [ "$$NEED_INSTALL" -eq 1 ]; then \
echo "Running playwright install --with-deps chromium..."; \
uv pip install playwright; \
PLAYWRIGHT_BIN=$$(uv run which playwright 2>/dev/null); \
PLAYWRIGHT_VERSION="$$( "$$PLAYWRIGHT_BIN" --version 2>/dev/null | awk '{print $$2}' || echo unknown )"; \
PLAYWRIGHT_SENTINEL="cache/playwright_chromium_is_installed.$$PLAYWRIGHT_VERSION.txt"; \
"$$PLAYWRIGHT_BIN" install --with-deps chromium; \
mkdir -p cache; \
touch "$$PLAYWRIGHT_SENTINEL"; \
else \
echo "Playwright setup already done (sentinel: $$PLAYWRIGHT_SENTINEL). Skipping."; \
fi; \
fi; \
else \
echo "Skipping Playwright installation (INSTALL_PLAYWRIGHT=${INSTALL_PLAYWRIGHT})."; \
fi
@@ -182,15 +172,15 @@ install-frontend-dependencies: check-npm check-nodejs
@cd frontend && npm install
@echo "$(GREEN)Frontend dependencies installed successfully.$(RESET)"
install-pre-commit-hooks: check-python check-poetry install-python-dependencies
install-pre-commit-hooks: check-uv install-python-dependencies
@echo "$(YELLOW)Installing pre-commit hooks...$(RESET)"
@git config --unset-all core.hooksPath || true
@poetry run pre-commit install --config $(PRE_COMMIT_CONFIG_PATH)
@uv run pre-commit install --config $(PRE_COMMIT_CONFIG_PATH)
@echo "$(GREEN)Pre-commit hooks installed successfully.$(RESET)"
lint-backend: install-pre-commit-hooks
@echo "$(YELLOW)Running linters...$(RESET)"
@poetry run pre-commit run --all-files --show-diff-on-failure --config $(PRE_COMMIT_CONFIG_PATH)
@uv run pre-commit run --all-files --show-diff-on-failure --config $(PRE_COMMIT_CONFIG_PATH)
lint-frontend: install-frontend-dependencies
@echo "$(YELLOW)Running linters for frontend...$(RESET)"
@@ -248,7 +238,7 @@ build-frontend:
# Start backend
start-backend:
@echo "$(YELLOW)Starting backend...$(RESET)"
@poetry run uvicorn openhands.server.listen:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) --reload --reload-exclude "./workspace"
@uv run uvicorn openhands.server.listen:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) --reload --reload-exclude "./workspace"
# Start frontend
start-frontend:
@@ -270,7 +260,7 @@ _run_setup:
fi
@mkdir -p logs
@echo "$(YELLOW)Starting backend server...$(RESET)"
@poetry run uvicorn openhands.server.listen:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) &
@uv run uvicorn openhands.server.listen:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) &
@echo "$(YELLOW)Waiting for the backend to start...$(RESET)"
@until nc -z localhost $(BACKEND_PORT); do sleep 0.1; done
@echo "$(GREEN)Backend started successfully.$(RESET)"
@@ -296,7 +286,6 @@ docker-run:
docker compose up $(OPTIONS); \
fi
# Setup config.toml
setup-config:
@echo "$(YELLOW)Setting up config.toml...$(RESET)"
@@ -306,21 +295,16 @@ setup-config:
setup-config-prompts:
@echo "[core]" > $(CONFIG_FILE).tmp
@read -p "Enter your workspace directory (as absolute path) [default: $(DEFAULT_WORKSPACE_DIR)]: " workspace_dir; \
workspace_dir=$${workspace_dir:-$(DEFAULT_WORKSPACE_DIR)}; \
echo "workspace_base=\"$$workspace_dir\"" >> $(CONFIG_FILE).tmp
@echo "" >> $(CONFIG_FILE).tmp
@echo "[llm]" >> $(CONFIG_FILE).tmp
@read -p "Enter your LLM model name, used for running without UI. Set the model in the UI after you start the app. (see https://docs.litellm.ai/docs/providers for full list) [default: $(DEFAULT_MODEL)]: " llm_model; \
llm_model=$${llm_model:-$(DEFAULT_MODEL)}; \
echo "model=\"$$llm_model\"" >> $(CONFIG_FILE).tmp
@read -p "Enter your LLM api key: " llm_api_key; \
echo "api_key=\"$$llm_api_key\"" >> $(CONFIG_FILE).tmp
@read -p "Enter your LLM base URL [mostly used for local LLMs, leave blank if not needed - example: http://localhost:5001/v1/]: " llm_base_url; \
if [[ ! -z "$$llm_base_url" ]]; then echo "base_url=\"$$llm_base_url\"" >> $(CONFIG_FILE).tmp; fi
@@ -367,5 +351,4 @@ help:
@echo " $(GREEN)help$(RESET) - Display this help message, providing information on available targets."
# Phony targets
.PHONY: build check-dependencies check-system check-python check-npm check-nodejs check-docker check-poetry install-python-dependencies install-frontend-dependencies install-pre-commit-hooks lint-backend lint-frontend lint test-frontend test build-frontend start-backend start-frontend _run_setup run run-wsl setup-config setup-config-prompts setup-config-basic openhands-cloud-run docker-dev docker-run clean help
.PHONY: kind
.PHONY: build check-dependencies check-system check-python check-npm check-nodejs check-docker install-python-dependencies install-frontend-dependencies install-pre-commit-hooks lint-backend lint-frontend lint test-frontend test build-frontend start-backend start-frontend _run_setup run run-wsl setup-config setup-config-prompts setup-config-basic openhands-cloud-run docker-dev docker-run clean help kind

View File

@@ -11,8 +11,7 @@
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
<br/>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community"></a>
<a href="https://all-hands.dev/joinslack"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
<br/>
<a href="https://docs.all-hands.dev/usage/getting-started"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="Check out the documentation"></a>
@@ -44,8 +43,6 @@ Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for
> [this short form](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
> to join our Design Partner program, where you'll get early access to commercial features and the opportunity to provide input on our product roadmap.
![App screenshot](./docs/static/img/screenshot.png)
## ☁️ OpenHands Cloud
The easiest way to get started with OpenHands is on [OpenHands Cloud](https://app.all-hands.dev),
which comes with $20 in free credits for new users.
@@ -79,17 +76,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
docker.all-hands.dev/all-hands-ai/openhands:0.58
```
</details>
@@ -103,7 +100,7 @@ docker run -it --rm --pull=always \
### Getting Started
When you open the application, you'll be asked to choose an LLM provider and add an API key.
[Anthropic's Claude Sonnet 4](https://www.anthropic.com/api) (`anthropic/claude-sonnet-4-20250514`)
[Anthropic's Claude Sonnet 4.5](https://www.anthropic.com/api) (`anthropic/claude-sonnet-4-5-20250929`)
works best, but you have [many options](https://docs.all-hands.dev/usage/llms).
See the [Running OpenHands](https://docs.all-hands.dev/usage/installation) guide for
@@ -130,7 +127,6 @@ If you want to modify the OpenHands source code, check out [Development.md](http
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/usage/troubleshooting) can help.
## 📖 Documentation
<a href="https://deepwiki.com/All-Hands-AI/OpenHands"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" title="Autogenerated Documentation by DeepWiki"></a>
To learn more about the project, and for tips on using OpenHands,
check out our [documentation](https://docs.all-hands.dev/usage/getting-started).
@@ -141,10 +137,9 @@ troubleshooting resources, and advanced configuration options.
## 🤝 How to Join the Community
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
through Slack, so this is the best place to start, but we also are happy to have you contact us on Discord or Github:
through Slack, so this is the best place to start, but we also are happy to have you contact us on Github:
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA) - Here we talk about research, architecture, and future development.
- [Join our Discord server](https://discord.gg/ESHStjSjD4) - This is a community-run server for general discussion, questions, and feedback.
- [Join our Slack workspace](https://all-hands.dev/joinslack) - Here we talk about research, architecture, and future development.
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
See more about the community in [COMMUNITY.md](./COMMUNITY.md) or find details on contributing in [CONTRIBUTING.md](./CONTRIBUTING.md).
@@ -161,7 +156,7 @@ See the monthly OpenHands roadmap [here](https://github.com/orgs/All-Hands-AI/pr
## 📜 License
Distributed under the MIT License. See [`LICENSE`](./LICENSE) for more information.
Distributed under the MIT License, with the exception of the `enterprise/` folder. See [`LICENSE`](./LICENSE) for more information.
## 🙏 Acknowledgements

View File

@@ -1,148 +0,0 @@
<a name="readme-top"></a>
<div align="center">
<img src="./docs/static/img/logo.png" alt="Logo" width="200">
<h1 align="center">OpenHands: 少写代码,多做事</h1>
</div>
<div align="center">
<a href="https://github.com/All-Hands-AI/OpenHands/graphs/contributors"><img src="https://img.shields.io/github/contributors/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Contributors"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
<br/>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="加入我们的Slack社区"></a>
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="加入我们的Discord社区"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="致谢"></a>
<br/>
<a href="https://docs.all-hands.dev/usage/getting-started"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="查看文档"></a>
<a href="https://arxiv.org/abs/2407.16741"><img src="https://img.shields.io/badge/Paper%20on%20Arxiv-000?logoColor=FFE165&logo=arxiv&style=for-the-badge" alt="Arxiv论文"></a>
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0#gid=0"><img src="https://img.shields.io/badge/Benchmark%20score-000?logoColor=FFE165&logo=huggingface&style=for-the-badge" alt="评估基准分数"></a>
<hr>
</div>
欢迎使用OpenHands前身为OpenDevin这是一个由AI驱动的软件开发代理平台。
OpenHands代理可以完成人类开发者能做的任何事情修改代码、运行命令、浏览网页、调用API甚至从StackOverflow复制代码片段。
在[docs.all-hands.dev](https://docs.all-hands.dev)了解更多信息,或[注册OpenHands Cloud](https://app.all-hands.dev)开始使用。
> [!IMPORTANT]
> 在工作中使用OpenHands我们很想与您交流填写
> [这份简短表格](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
> 加入我们的设计合作伙伴计划,您将获得商业功能的早期访问权限,并有机会对我们的产品路线图提供意见。
![应用截图](./docs/static/img/screenshot.png)
## ☁️ OpenHands Cloud
开始使用OpenHands的最简单方式是在[OpenHands Cloud](https://app.all-hands.dev)上,
新用户可获得$50的免费额度。
## 💻 在本地运行OpenHands
OpenHands也可以使用Docker在本地系统上运行。
查看[运行OpenHands](https://docs.all-hands.dev/usage/installation)指南了解
系统要求和更多信息。
> [!WARNING]
> 在公共网络上?请参阅我们的[强化Docker安装指南](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)
> 通过限制网络绑定和实施其他安全措施来保护您的部署。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
```
> **注意**: 如果您在0.44版本之前使用过OpenHands您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
您将在[http://localhost:3000](http://localhost:3000)找到运行中的OpenHands
打开应用程序时您将被要求选择一个LLM提供商并添加API密钥。
[Anthropic的Claude Sonnet 4](https://www.anthropic.com/api)`anthropic/claude-sonnet-4-20250514`
效果最佳,但您还有[许多选择](https://docs.all-hands.dev/usage/llms)。
## 💡 运行OpenHands的其他方式
> [!CAUTION]
> OpenHands旨在由单个用户在其本地工作站上运行。
> 它不适合多租户部署,即多个用户共享同一实例。没有内置的身份验证、隔离或可扩展性。
>
> 如果您有兴趣在多租户环境中运行OpenHands
> [与我们联系](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
> 了解高级部署选项。
您还可以[将OpenHands连接到本地文件系统](https://docs.all-hands.dev/usage/runtimes/docker#connecting-to-your-filesystem)
以可编程的[无头模式](https://docs.all-hands.dev/usage/how-to/headless-mode)运行OpenHands
通过[友好的CLI](https://docs.all-hands.dev/usage/how-to/cli-mode)与其交互,
或使用[GitHub Action](https://docs.all-hands.dev/usage/how-to/github-action)在标记的问题上运行它。
访问[运行OpenHands](https://docs.all-hands.dev/usage/installation)获取更多信息和设置说明。
如果您想修改OpenHands源代码请查看[Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md)。
遇到问题?[故障排除指南](https://docs.all-hands.dev/usage/troubleshooting)可以提供帮助。
## 📖 文档
<a href="https://deepwiki.com/All-Hands-AI/OpenHands"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" title="DeepWiki自动生成文档"></a>
要了解有关项目的更多信息以及使用OpenHands的技巧
请查看我们的[文档](https://docs.all-hands.dev/usage/getting-started)。
在那里您将找到有关如何使用不同LLM提供商、
故障排除资源和高级配置选项的资源。
## 🤝 如何加入社区
OpenHands是一个社区驱动的项目我们欢迎每个人的贡献。我们大部分沟通
通过Slack进行因此这是开始的最佳场所但我们也很乐意您通过Discord或Github与我们联系
- [加入我们的Slack工作空间](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA) - 这里我们讨论研究、架构和未来发展。
- [加入我们的Discord服务器](https://discord.gg/ESHStjSjD4) - 这是一个社区运营的服务器,用于一般讨论、问题和反馈。
- [阅读或发布Github问题](https://github.com/All-Hands-AI/OpenHands/issues) - 查看我们正在处理的问题,或添加您自己的想法。
在[COMMUNITY.md](./COMMUNITY.md)中了解更多关于社区的信息,或在[CONTRIBUTING.md](./CONTRIBUTING.md)中找到有关贡献的详细信息。
## 📈 进展
在[这里](https://github.com/orgs/All-Hands-AI/projects/1)查看OpenHands月度路线图每月月底在维护者会议上更新
<p align="center">
<a href="https://star-history.com/#All-Hands-AI/OpenHands&Date">
<img src="https://api.star-history.com/svg?repos=All-Hands-AI/OpenHands&type=Date" width="500" alt="Star History Chart">
</a>
</p>
## 📜 许可证
根据MIT许可证分发。有关更多信息请参阅[`LICENSE`](./LICENSE)。
## 🙏 致谢
OpenHands由大量贡献者构建每一份贡献都备受感谢我们还借鉴了其他开源项目对他们的工作深表感谢。
有关OpenHands中使用的开源项目和许可证列表请参阅我们的[CREDITS.md](./CREDITS.md)文件。
## 📚 引用
```
@misc{openhands,
title={{OpenHands: An Open Platform for AI Software Developers as Generalist Agents}},
author={Xingyao Wang and Boxuan Li and Yufan Song and Frank F. Xu and Xiangru Tang and Mingchen Zhuge and Jiayi Pan and Yueqi Song and Bowen Li and Jaskirat Singh and Hoang H. Tran and Fuqiang Li and Ren Ma and Mingzhang Zheng and Bill Qian and Yanjun Shao and Niklas Muennighoff and Yizhe Zhang and Binyuan Hui and Junyang Lin and Robert Brennan and Hao Peng and Heng Ji and Graham Neubig},
year={2024},
eprint={2407.16741},
archivePrefix={arXiv},
primaryClass={cs.SE},
url={https://arxiv.org/abs/2407.16741},
}
```

View File

@@ -1,60 +0,0 @@
<a name="readme-top"></a>
<div align="center">
<img src="./docs/static/img/logo.png" alt="Logo" width="200">
<h1 align="center">OpenHands: コードを減らして、もっと作ろう</h1>
</div>
<div align="center">
<a href="https://github.com/All-Hands-AI/OpenHands/graphs/contributors"><img src="https://img.shields.io/github/contributors/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Contributors"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
<br/>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Slackコミュニティに参加"></a>
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Discordコミュニティに参加"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="クレジット"></a>
<br/>
<a href="https://docs.all-hands.dev/usage/getting-started"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="ドキュメントを見る"></a>
<a href="https://arxiv.org/abs/2407.16741"><img src="https://img.shields.io/badge/Paper%20on%20Arxiv-000?logoColor=FFE165&logo=arxiv&style=for-the-badge" alt="Arxiv論文"></a>
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0#gid=0"><img src="https://img.shields.io/badge/Benchmark%20score-000?logoColor=FFE165&logo=huggingface&style=for-the-badge" alt="評価ベンチマークスコア"></a>
<hr>
</div>
OpenHands旧OpenDevinへようこそ。これはAIが駆動するソフトウェア開発エージェントのプラットフォームです。
OpenHandsのエージェントは人間の開発者ができることは何でもこなします。コードを修正し、コマンドを実行し、ウェブを閲覧し、APIを呼び出し、StackOverflowからコードスニペットをコピーすることさえできます。
詳細は[docs.all-hands.dev](https://docs.all-hands.dev)をご覧いただくか、[OpenHands Cloud](https://app.all-hands.dev)に登録して始めましょう。
> [!IMPORTANT]
> 仕事でOpenHandsを使っていますかぜひお話を聞かせてください。[こちらの短いフォーム](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)にご記入いただき、Design Partnerプログラムにご参加ください。商用機能の早期アクセスや製品ロードマップへのフィードバックの機会を提供します。
![アプリのスクリーンショット](./docs/static/img/screenshot.png)
## ☁️ OpenHands Cloud
OpenHandsを始める最も簡単な方法は[OpenHands Cloud](https://app.all-hands.dev)を利用することです。新規ユーザーには50ドル分の無料クレジットが付与されます。
## 💻 OpenHandsをローカルで実行する
OpenHandsはDockerを利用してローカル環境でも実行できます。システム要件や詳細については[Running OpenHands](https://docs.all-hands.dev/usage/installation)ガイドをご覧ください。
> [!WARNING]
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
OpenHandsは[http://localhost:3000](http://localhost:3000)で起動します!

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
set -e
poetry build -v
uv build -v

View File

@@ -2,7 +2,7 @@ import os
import pathlib
import subprocess
# This script is intended to be run by Poetry during the build process.
# This script is intended to be run during the build process.
# Define the expected name of the .vsix file based on the extension's package.json
# This should match the name and version in openhands-vscode/package.json
@@ -93,18 +93,18 @@ def build_vscode_extension():
def build(setup_kwargs):
"""This function is called by Poetry during the build process.
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
"""This function is called by the build backend (PEP 517) during the build process.
`setup_kwargs` will be forwarded to the underlying build backend (e.g., setuptools).
"""
print('--- Running custom Poetry build script (build_vscode.py) ---')
print('--- Running custom build script (build_vscode.py) ---')
# Build the VS Code extension and place the .vsix file
build_vscode_extension()
# Poetry will handle including files based on pyproject.toml `include` patterns.
# Ensure openhands/integrations/vscode/*.vsix is included there.
# The build backend will include files based on your pyproject.toml configuration.
# Ensure openhands/integrations/vscode/*.vsix is included.
print('--- Custom Poetry build script (build_vscode.py) finished ---')
print('--- Custom build script (build_vscode.py) finished ---')
if __name__ == '__main__':

View File

@@ -219,6 +219,14 @@ correct_num = 5
api_key = ""
model = "gpt-4o"
# Example routing LLM configuration for multimodal model routing
# Uncomment and configure to enable model routing with a secondary model
#[llm.secondary_model]
#model = "kimi-k2"
#api_key = ""
#for_routing = true
#max_input_tokens = 128000
#################################### Agent ###################################
# Configuration for agents (group name starts with 'agent')
@@ -480,3 +488,55 @@ type = "noop"
# Run the runtime sandbox container in privileged mode for use with docker-in-docker
#privileged = false
#################################### MCP #####################################
# Configuration for Model Context Protocol (MCP) servers
# MCP allows OpenHands to communicate with external tool servers
##############################################################################
[mcp]
# SSE servers - Server-Sent Events transport (legacy)
#sse_servers = [
# # Basic SSE server with just a URL
# "http://localhost:8080/mcp/sse",
#
# # SSE server with authentication
# {url = "https://api.example.com/mcp/sse", api_key = "your-api-key"}
#]
# SHTTP servers - Streamable HTTP transport (recommended)
#shttp_servers = [
# # Basic SHTTP server with default 60s timeout
# "https://api.example.com/mcp/shttp",
#
# # SHTTP server with custom timeout for long-running tools
# {
# url = "https://api.example.com/mcp/shttp",
# api_key = "your-api-key",
# timeout = 180 # 3 minutes for processing-heavy tools (1-3600 seconds)
# }
#]
# Stdio servers - Direct process communication (development only)
#stdio_servers = [
# # Basic stdio server
# {name = "filesystem", command = "npx", args = ["@modelcontextprotocol/server-filesystem", "/"]},
#
# # Stdio server with environment variables
# {
# name = "fetch",
# command = "uvx",
# args = ["mcp-server-fetch"],
# env = {DEBUG = "true"}
# }
#]
#################################### Model Routing ############################
# Configuration for experimental model routing feature
# Enables intelligent switching between different LLM models for specific purposes
##############################################################################
[model_routing]
# Router to use for model selection
# Available options:
# - "noop_router" (default): No routing, always uses primary LLM
# - "multimodal_router": A router that switches between primary and secondary models, depending on whether the input is multimodal or not
#router_name = "noop_router"

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:24.3.0-bookworm-slim AS frontend-builder
FROM node:24.8-trixie-slim AS frontend-builder
WORKDIR /app
@@ -9,25 +9,28 @@ RUN npm ci
COPY frontend ./
RUN npm run build
FROM python:3.12.10-slim AS base
FROM python:3.13.7-slim-trixie AS base
FROM base AS backend-builder
WORKDIR /app
ENV PYTHONPATH='/app'
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
ENV UV_CACHE_DIR=/tmp/uv_cache
RUN apt-get update -y \
&& apt-get install -y curl make git build-essential jq gettext \
&& python3 -m pip install poetry --break-system-packages
&& apt-get install -y curl make git build-essential jq gettext ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Download the latest installer
ADD https://astral.sh/uv/install.sh /uv-installer.sh
COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN export POETRY_CACHE_DIR && poetry install --no-root && rm -rf $POETRY_CACHE_DIR
# Run the installer then remove it
RUN sh /uv-installer.sh && rm /uv-installer.sh
# Ensure the installed binary is on the `PATH`
ENV PATH="/root/.local/bin/:$PATH"
COPY pyproject.toml ./
RUN uv venv && uv sync --no-install-project && rm -rf "${UV_CACHE_DIR}"
FROM base AS openhands-app
WORKDIR /app
@@ -76,7 +79,7 @@ COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_EN
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
COPY --chown=openhands:openhands pyproject.toml uv.lock README.md MANIFEST.in LICENSE ./
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
RUN python openhands/core/download.py # No-op to download assets

View File

@@ -65,9 +65,8 @@ RUN add-apt-repository ppa:deadsnakes/ppa \
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs
# Poetry >= 1.8
RUN curl -fsSL https://install.python-poetry.org | python3.12 - \
&& ln -s ~/.local/bin/poetry /usr/local/bin/poetry
# uv package manager
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
#
RUN <<EOF
@@ -82,7 +81,7 @@ git --version
python --version
echo node `node --version`
echo npm `npm --version`
poetry --version
uv --version
netcat -h 2>&1 | head -n 1
" > /version.sh
chmod a+x /version.sh

View File

@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.54-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.58-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -6,7 +6,7 @@ that depends on the `base_image` **AND** a [Python source distribution](https://
The following command will generate a `Dockerfile` file for `nikolaik/python-nodejs:python3.12-nodejs22` (the default base image), an updated `config.sh` and the runtime source distribution files/folders into `containers/runtime`:
```bash
poetry run python3 openhands/runtime/utils/runtime_build.py \
uv run python3 openhands/runtime/utils/runtime_build.py \
--base_image nikolaik/python-nodejs:python3.12-nodejs22 \
--build_folder containers/runtime
```

View File

@@ -3,9 +3,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- id: end-of-file-fixer
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- id: check-yaml
args: ["--allow-multiple-documents"]
- id: debug-statements
@@ -18,6 +18,7 @@ repos:
rev: v0.24.1
hooks:
- id: validate-pyproject
additional_dependencies: ["validate-pyproject-schema-store[all]"]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
@@ -28,19 +29,28 @@ repos:
entry: ruff check --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
args: [--fix, --unsafe-fixes]
exclude: third_party/
exclude: ^(third_party/|enterprise/)
# Run the formatter.
- id: ruff-format
entry: ruff format --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
exclude: third_party/
exclude: ^(third_party/|enterprise/)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
hooks:
- id: mypy
additional_dependencies:
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, types-Markdown, pydantic, lxml]
[
types-requests,
types-setuptools,
types-pyyaml,
types-toml,
types-docker,
types-Markdown,
pydantic,
lxml,
]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/
always_run: true

View File

@@ -7,9 +7,10 @@ warn_unreachable = True
warn_redundant_casts = True
no_implicit_optional = True
strict_optional = True
disable_error_code = type-abstract
# Exclude third-party runtime directory from type checking
exclude = third_party/
exclude = (third_party/|enterprise/)
[mypy-openhands.memory.condenser.impl.*]
disable_error_code = override

View File

@@ -1,5 +1,5 @@
# Exclude third-party runtime directory from linting
exclude = ["third_party/"]
exclude = ["third_party/", "enterprise/"]
[lint]
select = [

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -1,17 +1,36 @@
# Setup
# OpenHands Documentation
```
This directory contains the documentation for OpenHands. The documentation is automatically synchronized with the [All-Hands-AI/docs](https://github.com/All-Hands-AI/docs) repository, which hosts the unified documentation site using Mintlify.
## Documentation Structure
The documentation files in this directory are automatically included in the main documentation site via Git submodules. When you make changes to documentation in this repository, they will be automatically synchronized to the docs repository.
## How It Works
1. **Automatic Sync**: When documentation changes are pushed to the `main` branch, a GitHub Action automatically notifies the docs repository
2. **Submodule Update**: The docs repository updates its submodule reference to include your latest changes
3. **Site Rebuild**: Mintlify automatically rebuilds and deploys the documentation site
## Making Documentation Changes
Simply edit the documentation files in this directory as usual. The synchronization happens automatically when changes are merged to the main branch.
## Local Development
For local documentation development in this repository only:
```bash
npm install -g mint
```
or
```
# or
yarn global add mint
```
# Preview
```
# Preview local changes
mint dev
```
For the complete unified documentation site, work with the [All-Hands-AI/docs](https://github.com/All-Hands-AI/docs) repository.
## Configuration
The Mintlify configuration (`docs.json`) has been moved to the root of the [All-Hands-AI/docs](https://github.com/All-Hands-AI/docs) repository to enable unified documentation across multiple repositories.

View File

@@ -3,18 +3,16 @@
"theme": "mint",
"name": "All Hands Docs",
"colors": {
"primary": "#99873c",
"light": "#ffe165",
"dark": "#ffe165"
"primary": "#99873c"
},
"background": {
"color": {
"light": "#f7f3ee",
"dark": "#0B0D0E"
"light": "#f7f3ee"
}
},
"appearance": {
"default": "light"
"default": "light",
"strict": true
},
"favicon": "/logo-square.png",
"navigation": {
@@ -31,6 +29,7 @@
"group": "OpenHands Cloud",
"pages": [
"usage/cloud/openhands-cloud",
"usage/cloud/pro-subscription",
{
"group": "Integrations",
"pages": [
@@ -109,8 +108,7 @@
},
"usage/configuration-options",
"usage/how-to/custom-sandbox-guide",
"usage/search-engine-setup",
"usage/mcp"
"usage/search-engine-setup"
]
}
]
@@ -118,7 +116,13 @@
{
"group": "Customizations & Settings",
"pages": [
"usage/common-settings",
{
"group": "OpenHands Settings",
"pages": [
"usage/settings/secrets-settings",
"usage/settings/mcp-settings"
]
},
"usage/prompting/repository",
{
"group": "Microagents",
@@ -208,9 +212,8 @@
},
"footer": {
"socials": {
"slack": "https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA",
"github": "https://github.com/All-Hands-AI/OpenHands",
"discord": "https://discord.gg/ESHStjSjD4"
"slack": "https://all-hands.dev/joinslack",
"github": "https://github.com/All-Hands-AI/OpenHands"
}
},
"contextual": {

View File

@@ -4,7 +4,8 @@ description: OpenHands - Code Less, Make More
icon: book-open
mode: wide
---
Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer: they can modify code, run commands, browse the web, call APIs, and yes-even copy code snippets from StackOverflow.
Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer:
they can modify code, run commands, browse the web, call APIs, and yes-even copy code snippets from StackOverflow.
<iframe
className="w-full aspect-video"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

View File

@@ -90,7 +90,7 @@ This hash is built from the first 16 digits of the MD5 of:
- The name of the base image upon which the image was built (e.g.: `nikolaik/python-nodejs:python3.12-nodejs22`)
- The content of the `pyproject.toml` included in the image.
- The content of the `poetry.lock` included in the image.
- The content of the `uv.lock` included in the image.
This effectively gives a hash for the dependencies of Openhands independent of the source code.
@@ -105,7 +105,7 @@ When generating an image...
- **No re-build**: OpenHands first checks whether an image with the same **most specific source tag** exists. If there is such an image,
no build is performed - the existing image is used.
- **Fastest re-build**: OpenHands next checks whether an image with the **generic lock tag** exists. If there is such an image,
OpenHands builds a new image based upon it, bypassing all installation steps (like `poetry install` and
OpenHands builds a new image based upon it, bypassing all installation steps (like `uv sync` and
`apt-get`) except a final operation to copy the current source code. The new image is tagged with a
**source** tag only.
- **Ok-ish re-build**: If neither a **source** nor **lock** tag exists, an image will be built based upon the **versioned** tag image.
@@ -124,7 +124,7 @@ This tagging approach allows OpenHands to efficiently manage both development an
OpenHands supports both bind mounts and Docker named volumes in SandboxConfig.volumes:
- Bind mount: "/abs/host/path:/container/path[:mode]"
- Named volume: "volume:<name>:/container/path[:mode]" or any non-absolute host spec treated as a named volume
- Named volume: "volume:`<name>`:/container/path[:mode]" or any non-absolute host spec treated as a named volume
Overlay mode (copy-on-write layer) is supported for bind mounts by appending ":overlay" to the mode (e.g., ":ro,overlay").
To enable overlay COW, set SANDBOX_VOLUME_OVERLAYS to a writable host directory; per-container upper/work dirs are created under it. If SANDBOX_VOLUME_OVERLAYS is unset, overlay mounts are skipped.

View File

@@ -8,9 +8,21 @@ description: This guide walks you through the process of installing OpenHands Cl
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
## Working With Bitbucket Repos in Openhands Cloud
After signing in with a Bitbucket account, use the `Open Repository` section to select the appropriate repository and
branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
## IP Whitelisting
If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist the following IP addresses to allow OpenHands to access your repositories:
If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist the following IP addresses to allow
OpenHands to access your repositories:
### Core App IP
```
@@ -31,17 +43,6 @@ If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist t
34.60.55.59
```
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
## Working With Bitbucket Repos in Openhands Cloud
After signing in with a Bitbucket account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).

View File

@@ -12,104 +12,92 @@ For the available API endpoints, refer to the
To use the OpenHands Cloud API, you'll need to generate an API key:
1. Log in to your [OpenHands Cloud](https://app.all-hands.dev) account.
2. Navigate to the [Settings page](https://app.all-hands.dev/settings).
3. Select the `API Keys` tab.
4. Click `Create API Key`.
5. Give your key a descriptive name (Example: "Development" or "Production") and select `Create`.
6. Copy the generated API key and store it securely. It will only be shown once.
2. Navigate to the [Settings > API Keys](https://app.all-hands.dev/settings/api-keys) page.
3. Click `Create API Key`.
4. Give your key a descriptive name (Example: "Development" or "Production") and select `Create`.
5. Copy the generated API key and store it securely. It will only be shown once.
![API Key Generation](/static/img/api-key-generation.png)
## API Usage
## API Usage Example
### Starting a New Conversation
To start a new conversation with OpenHands to perform a task, you'll need to make a POST request to the conversation endpoint.
To start a new conversation with OpenHands to perform a task,
[you'll need to make a POST request to the conversation endpoint](/api-reference/new-conversation).
#### Request Parameters
<Tabs>
<Tab title="cURL">
```bash
curl -X POST "https://app.all-hands.dev/api/conversations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}'
```
</Tab>
<Tab title="Python (with requests)">
```python
import requests
| Parameter | Type | Required | Description |
|--------------------|----------|----------|------------------------------------------------------------------------------------------------------|
| `initial_user_msg` | string | Yes | The initial message to start the conversation. |
| `repository` | string | No | Git repository name to provide context in the format `owner/repo`. You must have access to the repo. |
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
#### Examples
<Accordion title="cURL">
```bash
curl -X POST "https://app.all-hands.dev/api/conversations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}'
```
</Accordion>
<Accordion title="Python (with requests)">
```python
import requests
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
data = {
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}
response = requests.post(url, headers=headers, json=data)
conversation = response.json()
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
print(f"Status: {conversation['status']}")
```
</Accordion>
<Accordion title="TypeScript/JavaScript (with fetch)">
```typescript
const apiKey = "YOUR_API_KEY";
const url = "https://app.all-hands.dev/api/conversations";
const headers = {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
};
const data = {
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
repository: "yourusername/your-repo"
};
async function startConversation() {
try {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(data)
});
const conversation = await response.json();
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
console.log(`Status: ${conversation.status}`);
return conversation;
} catch (error) {
console.error("Error starting conversation:", error);
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
}
startConversation();
```
</Accordion>
data = {
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}
response = requests.post(url, headers=headers, json=data)
conversation = response.json()
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
print(f"Status: {conversation['status']}")
```
</Tab>
<Tab title="TypeScript/JavaScript (with fetch)">
```typescript
const apiKey = "YOUR_API_KEY";
const url = "https://app.all-hands.dev/api/conversations";
const headers = {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
};
const data = {
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
repository: "yourusername/your-repo"
};
async function startConversation() {
try {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(data)
});
const conversation = await response.json();
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
console.log(`Status: ${conversation.status}`);
return conversation;
} catch (error) {
console.error("Error starting conversation:", error);
}
}
startConversation();
```
</Tab>
</Tabs>
#### Response
@@ -128,42 +116,6 @@ You may receive an `AuthenticationError` if:
- You provided the wrong repository name.
- You don't have access to the repository.
### Retrieving Conversation Status
You can check the status of a conversation by making a GET request to the conversation endpoint.
#### Endpoint
```
GET https://app.all-hands.dev/api/conversations/{conversation_id}
```
#### Example
<Accordion title="cURL">
```bash
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
```
</Accordion>
#### Response
The response is formatted as follows:
```json
{
"conversation_id":"abc1234",
"title":"Update README.md",
"created_at":"2025-04-29T15:13:51.370706Z",
"last_updated_at":"2025-04-29T15:13:57.199210Z",
"status":"RUNNING",
"selected_repository":"yourusername/your-repo",
"trigger":"gui"
}
```
## Rate Limits
If you have too many conversations running at once, older conversations will be paused to limit the number of concurrent conversations.

View File

@@ -8,24 +8,39 @@ description: The Cloud UI provides a web interface for interacting with OpenHand
The landing page is where you can:
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud),
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) or
[a Bitbucket repo](/usage/cloud/bitbucket-installation#working-with-bitbucket-repos-in-openhands-cloud) to start working on.
- Launch an empty conversation using `New Conversation`.
- See `Suggested Tasks` for repositories that OpenHands has access to.
- Launch an empty conversation using `Launch from Scratch`.
- See your `Recent Conversations`.
## Settings
The Settings page allows you to:
Settings are divided across tabs, with each tab focusing on a specific area of configuration.
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
- [Install the OpenHands Slack app](/usage/cloud/slack-installation).
- Set application settings like your preferred language, notifications and other preferences.
- Add credits to your account.
- [Generate custom secrets](/usage/common-settings#secrets-management).
- [Create API keys to work with OpenHands programmatically](/usage/cloud/cloud-api).
- Change your email address.
- `User`
- Change your email address.
- `Integrations`
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
- [Install the OpenHands Slack app](/usage/cloud/slack-installation).
- `Application`
- Set your preferred language, notifications and other preferences.
- Toggle task suggestions on GitHub.
- Toggle Solvability Analysis.
- Set a maximum budget per conversation.
- Configure the username and email that OpenHands uses for commits.
- `LLM` (Available for [Pro subscription users](/usage/cloud/pro-subscription))
- Choose to use another LLM or use different models from the OpenHands provider.
- `Billing`
- Add credits for using the OpenHands provider.
- Cancel your `Pro subscription`.
- `Secrets`
- [Manage secrets](/usage/settings/secrets-settings).
- `API Keys`
- [Create API keys to work with OpenHands programmatically](/usage/cloud/cloud-api).
- `MCP`
- [Setup an MCP server](/usage/settings/mcp-settings)
## Key Features

View File

@@ -12,7 +12,7 @@ description: This guide walks you through the process of installing OpenHands Cl
You can grant OpenHands access to specific GitHub repositories:
1. Click on `Add GitHub repos` on the landing page.
1. Click on `+ Add GitHub Repos` in the repository selection dropdown.
2. Select your organization and choose the specific repositories to grant OpenHands access to.
<Accordion title="OpenHands permissions">
- OpenHands requests short-lived tokens (8-hour expiration) with these permissions:
@@ -34,20 +34,22 @@ You can grant OpenHands access to specific GitHub repositories:
## Modifying Repository Access
You can modify GitHub repository access at any time by:
- Selecting `Add GitHub repos` on the landing page or
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Integrations` tab
- Selecting `+ Add GitHub Repos` in the repository selection dropdown or
- Visiting the `Settings > Integrations` page and selecting `Configure GitHub Repositories`
## Working With GitHub Repos in Openhands Cloud
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the `select a repo`
and `select a branch` dropdowns to select the appropriate repository and branch you'd like OpenHands to work on. Then
click on `Launch` to start the conversation!
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the
`Open Repository` section to select the appropriate repository and branch you'd like OpenHands to work on. Then click
on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
## Working on Github Issues and Pull Requests Using Openhands
## Working on GitHub Issues and Pull Requests Using Openhands
Giving GitHub repository access to OpenHands also allows you to work on GitHub issues and pull requests directly.
To allow OpenHands to work directly from GitHub directly, you must
[give OpenHands access to your repository](/usage/cloud/github-installation#modifying-repository-access). Once access is
given, you can use OpenHands by labeling the issue or by tagging `@openhands`.
### Working with Issues
@@ -64,7 +66,12 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
- Request updates
- Get code explanations
**Important Note**: The `@openhands` mention functionality in pull requests only works if the pull request is both *to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate permissions to access both repositories.
<Note>
The `@openhands` mention functionality in pull requests only works if the pull request is both
*to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate
permissions to access both repositories.
</Note>
## Next Steps

View File

@@ -14,16 +14,17 @@ Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have acc
## Working With GitLab Repos in Openhands Cloud
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
After signing in with a Gitlab account, use the `Open Repository` section to select the appropriate repository and
branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
![Connect Repo](/static/img/connect-repo.png)
## Using Tokens with Reduced Scopes
OpenHands requests an API-scoped token during OAuth authentication. By default, this token is provided to the agent.
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
To restrict the agent's permissions, [you can define a custom secret](/usage/settings/secrets-settings) `GITLAB_TOKEN`,
which will override the default token assigned to the agent. While the high-permission API token is still requested
and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
## Working on GitLab Issues and Merge Requests Using Openhands
@@ -32,7 +33,8 @@ This feature works for personal projects and is available for group projects wit
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but
we are planning to improve this in a future release.
</Note>
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.

View File

@@ -0,0 +1,50 @@
---
title: "Pro Subscription"
description: "Learn about OpenHands Cloud Pro Subscription features and pricing."
---
## Overview
The OpenHands Pro Subscription unlocks additional features and better pricing when you run OpenHands conversations in
OpenHands Cloud.
## Base Features
All users start on the Pay-as-you-go plan and have access to these base features when they sign up:
* **Run multiple OpenHands conversations on OpenHands Cloud runtimes.**
* **API keys to the OpenHands LLM provider for use in OpenHands CLI or when running OpenHands on your own.**
* **$20 in initial OpenHands Cloud credits to get started.**
* **Support for GitHub, GitLab, Bitbucket, Slack, and more.**
## What you get with a Pro Subscription
The $20/month Pro subscription covers the cost of runtime compute in OpenHands Cloud, plus enables the following
features:
* **Bring Your Own LLM Keys:** Bring your own API keys from OpenAI, Anthropic, Mistral, and other providers.
* **Model Choice:** Unlocks access to OpenHands LLM provider models for use within OpenHands Cloud.
* **No Markup Pricing on LLM usage:** When you use the OpenHands LLM provider in OpenHands Cloud, you pay for
LLM usage at-cost (zero markup) based on API prices.
## Plan Comparison
Here are the key differences between Pay-as-you-go and Pro subscriptions:
### When running OpenHands conversations in OpenHands Cloud
| | Pay-as-you-go | Pro Subscription |
| :---- | ----- | ----- |
| Monthly price | None \- no commitment | $20/month |
| Can I bring my own LLM key? | No | ✅ Yes |
| Do I pay for LLM usage? | ✅ Yes | ✅ Yes |
| Can I select from different LLMs without bringing my own LLM key? | No \- defaults to Claude Sonnet 4 | ✅ Yes \- via OpenHands LLM provider <br/><br/>[*See models and pricing*](https://docs.all-hands.dev/usage/llms/openhands-llms#pricing) |
| How much am I charged for LLM usage? | **Marked up pricing** \- 2x Claude Sonnet 4 API prices. *This markup helps cover the cost of runtime compute.* | **No markup** \- 1x API prices. *The $20 monthly subscription covers the cost of runtime compute.* |
### When using the OpenHands LLM Provider outside of OpenHands Cloud
The following applies to **both** the Pay-as-you-go and Pro subscription:
| | Pay-as-you-go or Pro Subscription |
| :---- | :---- |
| Do I have access to multiple models via the OpenHands LLM provider? | ✅ Yes <br/><br/> [*See models and pricing*](https://docs.all-hands.dev/usage/llms/openhands-llms#pricing) |
| Can I generate and refresh OpenHands LLM API keys? | ✅ Yes |
| How much am I charged for LLM usage when I use the OpenHands LLM provider in other AI coding tools? | **No markup** \- pay 1x API prices <br/> [*See models and pricing*](https://docs.all-hands.dev/usage/llms/openhands-llms#pricing) <br/><br/> *Usage is deducted from your OpenHands Cloud credit balance.* <br/><br/> *The OpenHands LLM provider is available to all OpenHands Cloud users, and LLM usage is billed at-cost (zero markup). Use these models with OpenHands CLI, running OpenHands on your own, or even other AI coding agents\! [Learn more.](https://www.all-hands.dev/blog/access-state-of-the-art-llm-models-at-cost-via-openhands-gui-and-cli)* |

View File

@@ -13,7 +13,9 @@ description: This guide walks you through installing the OpenHands Slack app.
</iframe>
<Info>
OpenHands utilizes a large language model (LLM), which may generate responses that are inaccurate or incomplete. While we strive for accuracy, OpenHands' outputs are not guaranteed to be correct, and we encourage users to validate critical information independently.
OpenHands utilizes a large language model (LLM), which may generate responses that are inaccurate or incomplete.
While we strive for accuracy, OpenHands' outputs are not guaranteed to be correct, and we encourage users to
validate critical information independently.
</Info>
## Prerequisites
@@ -39,7 +41,7 @@ OpenHands utilizes a large language model (LLM), which may generate responses th
**Make sure your Slack workspace admin/owner has installed OpenHands Slack App first.**
Every user in the Slack workspace (including admins/owners) must link their OpenHands Cloud account to the OpenHands Slack App. To do this:
1. Visit [integrations settings](https://app.all-hands.dev/settings/integrations) in OpenHands Cloud.
1. Visit the [Settings > Integrations](https://app.all-hands.dev/settings/integrations) page in OpenHands Cloud.
2. Click `Install OpenHands Slack App`.
3. In the top right corner, select the workspace to install the OpenHands Slack app.
4. Review permissions and click allow.
@@ -57,7 +59,8 @@ To start a new conversation, you can mention `@openhands` in a new message or a
Once a conversation is started, all thread messages underneath it will be follow-up messages to OpenHands.
To send follow-up messages for the same conversation, mention `@openhands` in a thread reply to the original message. You must be the user who started the conversation.
To send follow-up messages for the same conversation, mention `@openhands` in a thread reply to the original message.
You must be the user who started the conversation.
## Example conversation

View File

@@ -8,6 +8,11 @@ description: This page outlines all available configuration options for OpenHand
In GUI Mode, any settings applied through the Settings UI will take precedence.
</Note>
<Note>
**Looking for Environment Variables?** All configuration options can also be set using environment variables.
See the [Environment Variables Reference](./environment-variables) for a complete list with examples.
</Note>
## Location of the `config.toml` File
When running OpenHands in CLI, headless, or development mode, you can use a project-specific `config.toml` file for configuration, which must be
@@ -18,6 +23,11 @@ specify a different path to the `config.toml` file.
The core configuration options are defined in the `[core]` section of the `config.toml` file.
Core configuration options can be set as environment variables by converting to uppercase. For example:
- `debug` → `DEBUG`
- `cache_dir` → `CACHE_DIR`
- `runtime` → `RUNTIME`
### Workspace
- `workspace_base` **(Deprecated)**
- Type: `str`
@@ -141,6 +151,11 @@ The LLM (Large Language Model) configuration options are defined in the `[llm]`
To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LLM_NUM_RETRIES`.
All LLM configuration options can be set as environment variables by prefixing with `LLM_` and converting to uppercase. For example:
- `model` → `LLM_MODEL`
- `api_key` → `LLM_API_KEY`
- `base_url` → `LLM_BASE_URL`
<Note>
For development setups, you can also define custom named LLM configurations. See [Custom LLM Configurations](./llms/custom-llm-configs) for details.
</Note>
@@ -277,6 +292,11 @@ For development setups, you can also define custom named LLM configurations. See
The agent configuration options are defined in the `[agent]` and `[agent.<agent_name>]` sections of the `config.toml` file.
Agent configuration options can be set as environment variables by prefixing with `AGENT_` and converting to uppercase. For example:
- `enable_browsing` → `AGENT_ENABLE_BROWSING`
- `function_calling` → `AGENT_FUNCTION_CALLING`
- `llm_config` → `AGENT_LLM_CONFIG`
### LLM Configuration
- `llm_config`
- Type: `str`
@@ -328,6 +348,11 @@ The sandbox configuration options are defined in the `[sandbox]` section of the
To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-e SANDBOX_TIMEOUT`.
All sandbox configuration options can be set as environment variables by prefixing with `SANDBOX_` and converting to uppercase. For example:
- `timeout` → `SANDBOX_TIMEOUT`
- `user_id` → `SANDBOX_USER_ID`
- `base_container_image` → `SANDBOX_BASE_CONTAINER_IMAGE`
### Execution
- `timeout`
- Type: `int`
@@ -390,6 +415,10 @@ The security configuration options are defined in the `[security]` section of th
To use these with the docker command, pass in `-e SECURITY_<option>`. Example: `-e SECURITY_CONFIRMATION_MODE`.
All security configuration options can be set as environment variables by prefixing with `SECURITY_` and converting to uppercase. For example:
- `confirmation_mode` → `SECURITY_CONFIRMATION_MODE`
- `security_analyzer` → `SECURITY_SECURITY_ANALYZER`
### Confirmation Mode
- `confirmation_mode`
- Type: `bool`

View File

@@ -0,0 +1,251 @@
---
title: Environment Variables Reference
description: Complete reference of all environment variables supported by OpenHands
---
This page provides a reference of environment variables that can be used to configure OpenHands. Environment variables provide an alternative to TOML configuration files and are particularly useful for containerized deployments, CI/CD pipelines, and cloud environments.
## Environment Variable Naming Convention
OpenHands follows a consistent naming pattern for environment variables:
- **Core settings**: Direct uppercase mapping (e.g., `debug` → `DEBUG`)
- **LLM settings**: Prefixed with `LLM_` (e.g., `model` → `LLM_MODEL`)
- **Agent settings**: Prefixed with `AGENT_` (e.g., `enable_browsing` → `AGENT_ENABLE_BROWSING`)
- **Sandbox settings**: Prefixed with `SANDBOX_` (e.g., `timeout` → `SANDBOX_TIMEOUT`)
- **Security settings**: Prefixed with `SECURITY_` (e.g., `confirmation_mode` → `SECURITY_CONFIRMATION_MODE`)
## Core Configuration Variables
These variables correspond to the `[core]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `DEBUG` | boolean | `false` | Enable debug logging throughout the application |
| `DISABLE_COLOR` | boolean | `false` | Disable colored output in terminal |
| `CACHE_DIR` | string | `"/tmp/cache"` | Directory path for caching |
| `SAVE_TRAJECTORY_PATH` | string | `"./trajectories"` | Path to store conversation trajectories |
| `REPLAY_TRAJECTORY_PATH` | string | `""` | Path to load and replay a trajectory file |
| `FILE_STORE_PATH` | string | `"/tmp/file_store"` | File store directory path |
| `FILE_STORE` | string | `"memory"` | File store type (`memory`, `local`, etc.) |
| `FILE_UPLOADS_MAX_FILE_SIZE_MB` | integer | `0` | Maximum file upload size in MB (0 = no limit) |
| `FILE_UPLOADS_RESTRICT_FILE_TYPES` | boolean | `false` | Whether to restrict file upload types |
| `FILE_UPLOADS_ALLOWED_EXTENSIONS` | list | `[".*"]` | List of allowed file extensions for uploads |
| `MAX_BUDGET_PER_TASK` | float | `0.0` | Maximum budget per task (0.0 = no limit) |
| `MAX_ITERATIONS` | integer | `100` | Maximum number of iterations per task |
| `RUNTIME` | string | `"docker"` | Runtime environment (`docker`, `local`, `cli`, etc.) |
| `DEFAULT_AGENT` | string | `"CodeActAgent"` | Default agent class to use |
| `JWT_SECRET` | string | auto-generated | JWT secret for authentication |
| `RUN_AS_OPENHANDS` | boolean | `true` | Whether to run as the openhands user |
| `VOLUMES` | string | `""` | Volume mounts in format `host:container[:mode]` |
## LLM Configuration Variables
These variables correspond to the `[llm]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `LLM_MODEL` | string | `"claude-3-5-sonnet-20241022"` | LLM model to use |
| `LLM_API_KEY` | string | `""` | API key for the LLM provider |
| `LLM_BASE_URL` | string | `""` | Custom API base URL |
| `LLM_API_VERSION` | string | `""` | API version to use |
| `LLM_TEMPERATURE` | float | `0.0` | Sampling temperature |
| `LLM_TOP_P` | float | `1.0` | Top-p sampling parameter |
| `LLM_MAX_INPUT_TOKENS` | integer | `0` | Maximum input tokens (0 = no limit) |
| `LLM_MAX_OUTPUT_TOKENS` | integer | `0` | Maximum output tokens (0 = no limit) |
| `LLM_MAX_MESSAGE_CHARS` | integer | `30000` | Maximum characters that will be sent to the model in observation content |
| `LLM_TIMEOUT` | integer | `0` | API timeout in seconds (0 = no timeout) |
| `LLM_NUM_RETRIES` | integer | `8` | Number of retry attempts |
| `LLM_RETRY_MIN_WAIT` | integer | `15` | Minimum wait time between retries (seconds) |
| `LLM_RETRY_MAX_WAIT` | integer | `120` | Maximum wait time between retries (seconds) |
| `LLM_RETRY_MULTIPLIER` | float | `2.0` | Exponential backoff multiplier |
| `LLM_DROP_PARAMS` | boolean | `false` | Drop unsupported parameters without error |
| `LLM_CACHING_PROMPT` | boolean | `true` | Enable prompt caching if supported |
| `LLM_DISABLE_VISION` | boolean | `false` | Disable vision capabilities for cost reduction |
| `LLM_CUSTOM_LLM_PROVIDER` | string | `""` | Custom LLM provider name |
| `LLM_OLLAMA_BASE_URL` | string | `""` | Base URL for Ollama API |
| `LLM_INPUT_COST_PER_TOKEN` | float | `0.0` | Cost per input token |
| `LLM_OUTPUT_COST_PER_TOKEN` | float | `0.0` | Cost per output token |
| `LLM_REASONING_EFFORT` | string | `""` | Reasoning effort for o-series models (`low`, `medium`, `high`) |
### AWS Configuration
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `LLM_AWS_ACCESS_KEY_ID` | string | `""` | AWS access key ID |
| `LLM_AWS_SECRET_ACCESS_KEY` | string | `""` | AWS secret access key |
| `LLM_AWS_REGION_NAME` | string | `""` | AWS region name |
## Agent Configuration Variables
These variables correspond to the `[agent]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `AGENT_LLM_CONFIG` | string | `""` | Name of LLM config group to use |
| `AGENT_FUNCTION_CALLING` | boolean | `true` | Enable function calling |
| `AGENT_ENABLE_BROWSING` | boolean | `false` | Enable browsing delegate |
| `AGENT_ENABLE_LLM_EDITOR` | boolean | `false` | Enable LLM-based editor |
| `AGENT_ENABLE_JUPYTER` | boolean | `false` | Enable Jupyter integration |
| `AGENT_ENABLE_HISTORY_TRUNCATION` | boolean | `true` | Enable history truncation |
| `AGENT_ENABLE_PROMPT_EXTENSIONS` | boolean | `true` | Enable microagents (prompt extensions) |
| `AGENT_DISABLED_MICROAGENTS` | list | `[]` | List of microagents to disable |
## Sandbox Configuration Variables
These variables correspond to the `[sandbox]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `SANDBOX_TIMEOUT` | integer | `120` | Sandbox timeout in seconds |
| `SANDBOX_USER_ID` | integer | `1000` | User ID for sandbox processes |
| `SANDBOX_BASE_CONTAINER_IMAGE` | string | `"nikolaik/python-nodejs:python3.12-nodejs22"` | Base container image |
| `SANDBOX_USE_HOST_NETWORK` | boolean | `false` | Use host networking |
| `SANDBOX_RUNTIME_BINDING_ADDRESS` | string | `"0.0.0.0"` | Runtime binding address |
| `SANDBOX_ENABLE_AUTO_LINT` | boolean | `false` | Enable automatic linting |
| `SANDBOX_INITIALIZE_PLUGINS` | boolean | `true` | Initialize sandbox plugins |
| `SANDBOX_RUNTIME_EXTRA_DEPS` | string | `""` | Extra dependencies to install |
| `SANDBOX_RUNTIME_STARTUP_ENV_VARS` | dict | `{}` | Environment variables for runtime |
| `SANDBOX_BROWSERGYM_EVAL_ENV` | string | `""` | BrowserGym evaluation environment |
| `SANDBOX_VOLUMES` | string | `""` | Volume mounts (replaces deprecated workspace settings) |
| `SANDBOX_RUNTIME_CONTAINER_IMAGE` | string | `""` | Pre-built runtime container image |
| `SANDBOX_KEEP_RUNTIME_ALIVE` | boolean | `false` | Keep runtime alive after session ends |
| `SANDBOX_PAUSE_CLOSED_RUNTIMES` | boolean | `false` | Pause instead of stopping closed runtimes |
| `SANDBOX_CLOSE_DELAY` | integer | `300` | Delay before closing idle runtimes (seconds) |
| `SANDBOX_RM_ALL_CONTAINERS` | boolean | `false` | Remove all containers when stopping |
| `SANDBOX_ENABLE_GPU` | boolean | `false` | Enable GPU support |
| `SANDBOX_CUDA_VISIBLE_DEVICES` | string | `""` | Specify GPU devices by ID |
| `SANDBOX_VSCODE_PORT` | integer | auto | Specific port for VSCode server |
### Sandbox Environment Variables
Variables prefixed with `SANDBOX_ENV_` are passed through to the sandbox environment:
| Environment Variable | Description |
|---------------------|-------------|
| `SANDBOX_ENV_*` | Any variable with this prefix is passed to the sandbox (e.g., `SANDBOX_ENV_OPENAI_API_KEY`) |
## Security Configuration Variables
These variables correspond to the `[security]` section in `config.toml`:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `SECURITY_CONFIRMATION_MODE` | boolean | `false` | Enable confirmation mode for actions |
| `SECURITY_SECURITY_ANALYZER` | string | `"llm"` | Security analyzer to use (`llm`, `invariant`) |
| `SECURITY_ENABLE_SECURITY_ANALYZER` | boolean | `true` | Enable security analysis |
## Debug and Logging Variables
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `DEBUG` | boolean | `false` | Enable general debug logging |
| `DEBUG_LLM` | boolean | `false` | Enable LLM-specific debug logging |
| `DEBUG_RUNTIME` | boolean | `false` | Enable runtime debug logging |
| `LOG_TO_FILE` | boolean | auto | Log to file (auto-enabled when DEBUG=true) |
## Runtime-Specific Variables
### Docker Runtime
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `SANDBOX_VOLUME_OVERLAYS` | string | `""` | Volume overlay configurations |
### Remote Runtime
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `SANDBOX_API_KEY` | string | `""` | API key for remote runtime |
| `SANDBOX_REMOTE_RUNTIME_API_URL` | string | `""` | Remote runtime API URL |
### Local Runtime
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `RUNTIME_URL` | string | `""` | Runtime URL for local runtime |
| `RUNTIME_URL_PATTERN` | string | `""` | Runtime URL pattern |
| `RUNTIME_ID` | string | `""` | Runtime identifier |
| `LOCAL_RUNTIME_MODE` | string | `""` | Enable local runtime mode (`1` to enable) |
## Integration Variables
### GitHub Integration
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `GITHUB_TOKEN` | string | `""` | GitHub personal access token |
### Third-Party API Keys
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `OPENAI_API_KEY` | string | `""` | OpenAI API key |
| `ANTHROPIC_API_KEY` | string | `""` | Anthropic API key |
| `GOOGLE_API_KEY` | string | `""` | Google API key |
| `AZURE_API_KEY` | string | `""` | Azure API key |
| `TAVILY_API_KEY` | string | `""` | Tavily search API key |
## Server Configuration Variables
These are primarily used when running OpenHands as a server:
| Environment Variable | Type | Default | Description |
|---------------------|------|---------|-------------|
| `FRONTEND_PORT` | integer | `3000` | Frontend server port |
| `BACKEND_PORT` | integer | `8000` | Backend server port |
| `FRONTEND_HOST` | string | `"localhost"` | Frontend host address |
| `BACKEND_HOST` | string | `"localhost"` | Backend host address |
| `WEB_HOST` | string | `"localhost"` | Web server host |
| `SERVE_FRONTEND` | boolean | `true` | Whether to serve frontend |
## Deprecated Variables
These variables are deprecated and should be replaced:
| Environment Variable | Replacement | Description |
|---------------------|-------------|-------------|
| `WORKSPACE_BASE` | `SANDBOX_VOLUMES` | Use volume mounting instead |
| `WORKSPACE_MOUNT_PATH` | `SANDBOX_VOLUMES` | Use volume mounting instead |
| `WORKSPACE_MOUNT_PATH_IN_SANDBOX` | `SANDBOX_VOLUMES` | Use volume mounting instead |
| `WORKSPACE_MOUNT_REWRITE` | `SANDBOX_VOLUMES` | Use volume mounting instead |
## Usage Examples
### Basic Setup with OpenAI
```bash
export LLM_MODEL="gpt-4o"
export LLM_API_KEY="your-openai-api-key"
export DEBUG=true
```
### Docker Deployment with Custom Volumes
```bash
export RUNTIME="docker"
export SANDBOX_VOLUMES="/host/workspace:/workspace:rw,/host/data:/data:ro"
export SANDBOX_TIMEOUT=300
```
### Remote Runtime Configuration
```bash
export RUNTIME="remote"
export SANDBOX_API_KEY="your-remote-api-key"
export SANDBOX_REMOTE_RUNTIME_API_URL="https://your-runtime-api.com"
```
### Security-Enhanced Setup
```bash
export SECURITY_CONFIRMATION_MODE=true
export SECURITY_SECURITY_ANALYZER="llm"
export DEBUG_RUNTIME=true
```
## Notes
1. **Boolean Values**: Environment variables expecting boolean values accept `true`/`false`, `1`/`0`, or `yes`/`no` (case-insensitive).
2. **List Values**: Lists should be provided as Python literal strings, e.g., `AGENT_DISABLED_MICROAGENTS='["microagent1", "microagent2"]'`.
3. **Dictionary Values**: Dictionaries should be provided as Python literal strings, e.g., `SANDBOX_RUNTIME_STARTUP_ENV_VARS='{"KEY": "value"}'`.
4. **Precedence**: Environment variables take precedence over TOML configuration files.
5. **Docker Usage**: When using Docker, pass environment variables with the `-e` flag:
```bash
docker run -e LLM_API_KEY="your-key" -e DEBUG=true openhands/openhands
```
6. **Validation**: Invalid environment variable values will be logged as errors and fall back to defaults.

View File

@@ -89,8 +89,7 @@ If you would like to set things up more systematically, you can:
1. **Search existing issues**: Check our [GitHub issues](https://github.com/All-Hands-AI/OpenHands/issues) to see if
others have encountered the same problem.
2. **Join our community**: Get help from other users and developers:
- [Slack community](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA)
- [Discord server](https://discord.gg/ESHStjSjD4)
- [Slack community](https://dub.sh/openhands)
3. **Check our troubleshooting guide**: Common issues and solutions are documented in
[Troubleshooting](/usage/troubleshooting/troubleshooting).
4. **Report bugs**: If you've found a bug, please [create an issue](https://github.com/All-Hands-AI/OpenHands/issues/new)

View File

@@ -88,9 +88,9 @@ source ~/.bashrc # or source ~/.zshrc
</AccordionGroup>
<Note>
If you have cloned the repository, you can also run the CLI directly using Poetry:
If you have cloned the repository, you can also run the CLI directly using uv:
poetry run openhands
uv run openhands
</Note>
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
@@ -105,7 +105,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
1. Set the following environment variables in your terminal:
- `SANDBOX_VOLUMES` to specify the directory you want OpenHands to access ([See using SANDBOX_VOLUMES for more info](../runtimes/docker#using-sandbox_volumes))
- `LLM_MODEL` - the LLM model to use (e.g. `export LLM_MODEL="anthropic/claude-sonnet-4-20250514"`)
- `LLM_MODEL` - the LLM model to use (e.g. `export LLM_MODEL="anthropic/claude-sonnet-4-20250514"` or `export LLM_MODEL="anthropic/claude-sonnet-4-5-20250929"`)
- `LLM_API_KEY` - your API key (e.g. `export LLM_API_KEY="sk_test_12345"`)
2. Run the following command:
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -122,7 +122,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
docker.all-hands.dev/all-hands-ai/openhands:0.58 \
python -m openhands.cli.entry --override-cli-mode true
```

View File

@@ -35,7 +35,7 @@ temperature = 0.0
OpenHands can be run from the command line using the following format:
```bash
poetry run python ./openhands/core/main.py \
uv run python -m openhands.core.main \
-i <max_iterations> \
-t "<task_description>" \
-c <agent_class> \
@@ -45,7 +45,7 @@ poetry run python ./openhands/core/main.py \
For example:
```bash
poetry run python ./openhands/core/main.py \
uv run python -m openhands.core.main \
-i 10 \
-t "Write me a bash script that prints hello world." \
-c CodeActAgent \

View File

@@ -85,11 +85,11 @@ You can use the Settings page at any time to:
- Setup the LLM provider and model for OpenHands.
- [Setup the search engine](/usage/search-engine-setup).
- [Configure MCP servers](/usage/mcp).
- [Configure MCP servers](/usage/settings/mcp-settings).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup), [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup)
and [connect to Bitbucket](/usage/how-to/gui-mode#bitbucket-setup).
- Set application settings like your preferred language, notifications and other preferences.
- [Manage custom secrets](/usage/common-settings#secrets-management).
- [Manage custom secrets](/usage/settings/secrets-settings).
#### GitHub Setup

View File

@@ -12,7 +12,7 @@ To run OpenHands in headless mode with Python:
1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
2. Run the following command:
```bash
poetry run python -m openhands.core.main -t "write a bash script that prints hi"
uv run python -m openhands.core.main -t "write a bash script that prints hi"
```
You'll need to be sure to set your model, API key, and other settings via environment variables
@@ -26,24 +26,24 @@ You can specify a repository for OpenHands to work with using `--selected-repo`
```bash
# Using command-line argument
poetry run python -m openhands.core.main \
uv run python -m openhands.core.main \
--selected-repo "owner/repo-name" \
-t "analyze the codebase and suggest improvements"
# Using environment variable
export SANDBOX_SELECTED_REPO="owner/repo-name"
poetry run python -m openhands.core.main -t "fix any linting issues"
uv run python -m openhands.core.main -t "fix any linting issues"
# Authentication tokens are currently required for ALL repository operations (public and private)
# This includes GitHub, GitLab, and Bitbucket repositories
export GITHUB_TOKEN="your-token" # or GITLAB_TOKEN, BITBUCKET_TOKEN
poetry run python -m openhands.core.main \
uv run python -m openhands.core.main \
--selected-repo "owner/repo-name" \
-t "review the security implementation"
# Using task files instead of inline task
echo "Review the README and suggest improvements" > task.txt
poetry run python -m openhands.core.main -f task.txt --selected-repo "owner/repo"
uv run python -m openhands.core.main -f task.txt --selected-repo "owner/repo"
```
## With Docker
@@ -52,8 +52,8 @@ Set environment variables and run the Docker command:
```bash
# Set required environment variables
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
export SANDBOX_VOLUMES="/path/to/workspace:/workspace:rw" # Format: host_path:container_path:mode
export LLM_MODEL="anthropic/claude-sonnet-4-20250514" # or "anthropic/claude-sonnet-4-5-20250929"
export LLM_API_KEY="your-api-key"
export SANDBOX_SELECTED_REPO="owner/repo-name" # Optional: requires GITHUB_TOKEN
export GITHUB_TOKEN="your-token" # Required for repository operations
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
docker.all-hands.dev/all-hands-ai/openhands:0.58 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
@@ -91,6 +91,6 @@ Common command-line options:
- `-b 10.0` - Set budget limit (USD)
- `--no-auto-continue` - Interactive mode
Run `poetry run python -m openhands.core.main --help` for all options.
Run `uv run python -m openhands.core.main --help` for all options.
Set `export LOG_ALL_EVENTS=true` to log all agent actions.

View File

@@ -4,16 +4,17 @@ description: Running OpenHands Cloud or running on your own.
icon: rocket
---
## OpenHands Cloud
<Tabs>
<Tab title="OpenHands Cloud">
The easiest way to get started with OpenHands is on OpenHands Cloud, which comes with $20 in free credits for new users.
The easiest way to get started with OpenHands is on OpenHands Cloud, which comes with $20 in free credits for new users.
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
</Tab>
<Tab title="Running OpenHands on Your Own">
Run OpenHands on your local system and bring your own LLM and API key.
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
## Running OpenHands on Your Own
Run OpenHands on your local system and bring your own LLM and API key.
For more information see [running OpenHands on your own.](/usage/local-setup)
For more information see [running OpenHands on your own.](/usage/local-setup)
</Tab>
</Tabs>

View File

@@ -18,6 +18,7 @@ Based on these findings and community feedback, these are the latest models that
### Cloud / API-Based Models
- [anthropic/claude-sonnet-4-20250514](https://www.anthropic.com/api) (recommended)
- [anthropic/claude-sonnet-4-5-20250929](https://www.anthropic.com/api) (recommended)
- [openai/gpt-5-2025-08-07](https://openai.com/api/) (recommended)
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)

View File

@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
docker.all-hands.dev/all-hands-ai/openhands:0.58
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.54
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.58
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
@@ -119,7 +119,7 @@ When started for the first time, OpenHands will prompt you to set up the LLM pro
That's it! You can now start using OpenHands with the local LLM server.
If you encounter any issues, let us know on [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA) or [Discord](https://discord.gg/ESHStjSjD4).
If you encounter any issues, let us know on [Slack](https://dub.sh/openhands).
## Advanced: Alternative LLM Backends

View File

@@ -15,7 +15,7 @@ description: OpenHands LLM provider with access to state-of-the-art (SOTA) agent
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `OpenHands`
- `LLM Model` to the model you will be using (e.g. claude-sonnet-4-20250514)
- `LLM Model` to the model you will be using (e.g. claude-sonnet-4-20250514 or claude-sonnet-4-5-20250929)
- `API Key` to your OpenHands LLM API key copied from above
## Using OpenHands LLM Provider in the CLI
@@ -30,6 +30,21 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
## Pricing
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
Pricing follows official API provider rates. Below are the current pricing details for OpenHands models:
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: \$0.4 per million input tokens and \$1.6 per million output tokens.
| Model | Input Cost (per 1M tokens) | Cached Input Cost (per 1M tokens) | Output Cost (per 1M tokens) | Max Input Tokens | Max Output Tokens |
|-------|----------------------------|-----------------------------------|------------------------------|------------------|-------------------|
| claude-opus-4-20250514 | $15.00 | $1.50 | $75.00 | 200,000 | 32,000 |
| claude-sonnet-4-20250514 | $3.00 | $0.30 | $15.00 | 200,000 | 64,000 |
| claude-sonnet-4-5-20250929 | $3.00 | $0.30 | $15.00 | 200,000 | 64,000 |
| devstral-medium-2507 | $0.40 | N/A | $2.00 | 128,000 | 128,000 |
| devstral-small-2505 | $0.10 | N/A | $0.30 | 128,000 | 128,000 |
| devstral-small-2507 | $0.10 | N/A | $0.30 | 128,000 | 128,000 |
| gemini-2.5-pro | $1.25 | $0.31 | $10.00 | 1,048,576 | 65,535 |
| gpt-5-2025-08-07 | $1.25 | $0.125 | $10.00 | 400,000 | 128,000 |
| gpt-5-mini-2025-08-07 | $0.25 | $0.025 | $2.00 | 400,000 | 128,000 |
| o3 | $2.00 | $0.50 | $8.00 | 200,000 | 100,000 |
| o4-mini | $1.10 | $0.28 | $4.40 | 200,000 | 100,000 |
| qwen3-coder-480b | $0.40 | N/A | $1.60 | N/A | N/A |
**Note:** Cached input tokens are charged at a reduced rate when the same content is reused across requests. Models that don't support prompt caching show "N/A" for cached input cost.

View File

@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
<Accordion title="Docker Command (Click to expand)">
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
docker.all-hands.dev/all-hands-ai/openhands:0.58
```
</Accordion>

View File

@@ -1,196 +0,0 @@
---
title: Model Context Protocol (MCP)
description: This page outlines how to configure and use the Model Context Protocol (MCP) in OpenHands, allowing you
to extend the agent's capabilities with custom tools.
---
## Overview
Model Context Protocol (MCP) is a mechanism that allows OpenHands to communicate with external tool servers. These
servers can provide additional functionality to the agent, such as specialized data processing, external API access,
or custom tools. MCP is based on the open standard defined at [modelcontextprotocol.io](https://modelcontextprotocol.io).
<Note>
MCP is currently not available on OpenHands Cloud. This feature is only available when running OpenHands locally.
</Note>
### How MCP Works
When OpenHands starts, it:
1. Reads the MCP configuration.
2. Connects to any configured SSE and SHTTP servers.
3. Starts any configured stdio servers.
4. Registers the tools provided by these servers with the agent.
The agent can then use these tools just like any built-in tool. When the agent calls an MCP tool:
1. OpenHands routes the call to the appropriate MCP server.
2. The server processes the request and returns a response.
3. OpenHands converts the response to an observation and presents it to the agent.
## Configuration
MCP configuration can be defined in:
* The OpenHands UI through the Settings under the `MCP` tab.
* The `config.toml` file under the `[mcp]` section if not using the UI.
### Configuration Examples
#### Recommended: Using Proxy Servers (SSE/HTTP)
For stdio-based MCP servers, we recommend using MCP proxy tools like [`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to HTTP/SSE endpoints:
Start the proxy servers separately:
```bash
# Terminal 1: Filesystem server proxy
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
# Terminal 2: Fetch server proxy
supergateway --stdio "uvx mcp-server-fetch" --port 8081
```
Then configure OpenHands to use the HTTP endpoint:
```toml
[mcp]
# SSE Servers - Recommended approach using proxy tools
sse_servers = [
# Basic SSE server with just a URL
"http://example.com:8080/mcp",
# SuperGateway proxy for fetch server
"http://localhost:8081/sse",
# External MCP service with authentication
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
]
```
#### Alternative: Direct Stdio Servers (Not Recommended for Production)
```toml
[mcp]
# Direct stdio servers - use only for development/testing
stdio_servers = [
# Basic stdio server
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
# Stdio server with environment variables
{
name="filesystem",
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/"],
env={
"DEBUG": "true"
}
}
]
```
## Configuration Options
### SSE Servers
SSE servers are configured using either a string URL or an object with the following properties:
- `url` (required)
- Type: `str`
- Description: The URL of the SSE server
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
### SHTTP Servers
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
- `url` (required)
- Type: `str`
- Description: The URL of the SHTTP server
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
### Stdio Servers
**Note**: While stdio servers are supported, we recommend using MCP proxies (see above) for better reliability and performance.
Stdio servers are configured using an object with the following properties:
- `name` (required)
- Type: `str`
- Description: A unique name for the server
- `command` (required)
- Type: `str`
- Description: The command to run the server
- `args` (optional)
- Type: `list of str`
- Default: `[]`
- Description: Command-line arguments to pass to the server
- `env` (optional)
- Type: `dict of str to str`
- Default: `{}`
- Description: Environment variables to set for the server process
#### When to Use Direct Stdio
Direct stdio connections may still be appropriate in these scenarios:
- **Development and testing**: Quick prototyping of MCP servers
- **Simple, single-use tools**: Tools that don't require high reliability or concurrent access
- **Local-only environments**: When you don't want to manage additional proxy processes
For production use, we recommend using proxy tools like SuperGateway.
### Other Proxy Tools
Other options include:
- **Custom FastAPI/Express servers**: Build your own HTTP wrapper around stdio MCP servers
- **Docker-based proxies**: Containerized solutions for better isolation
- **Cloud-hosted MCP services**: Third-party services that provide MCP endpoints
### Troubleshooting MCP Connections
#### Common Issues with Stdio Servers
- **Process crashes**: Stdio processes may crash without proper error handling
- **Deadlocks**: Stdio communication can deadlock under high load
- **Resource leaks**: Zombie processes if not properly managed
- **Debugging difficulty**: Hard to inspect stdio communication
#### Benefits of Using Proxies
- **HTTP status codes**: Clear error reporting via standard HTTP responses
- **Request logging**: Easy to log and monitor HTTP requests
- **Load balancing**: Can distribute requests across multiple server instances
- **Health checks**: HTTP endpoints can provide health status
- **CORS support**: Better integration with web-based tools
## Transport Protocols
OpenHands supports three different MCP transport protocols:
### Server-Sent Events (SSE)
SSE is a legacy HTTP-based transport that uses Server-Sent Events for server-to-client communication and HTTP POST requests for client-to-server communication. This transport is suitable for basic streaming scenarios but has limitations in session management and connection resumability.
### Streamable HTTP (SHTTP)
SHTTP is the modern HTTP-based transport protocol that provides enhanced features over SSE:
- **Improved Session Management**: Supports stateful sessions with session IDs for maintaining context across requests
- **Connection Resumability**: Can resume broken connections and replay missed messages using event IDs
- **Bidirectional Communication**: Uses HTTP POST for client-to-server and optional SSE streams for server-to-client communication
- **Better Error Handling**: Enhanced error reporting and recovery mechanisms
SHTTP is the recommended transport for HTTP-based MCP servers as it provides better reliability and features compared to the legacy SSE transport.
### Standard Input/Output (stdio)
Stdio transport enables communication through standard input and output streams, making it ideal for local integrations and command-line tools. This transport is used for locally executed MCP servers that run as separate processes.

View File

@@ -60,7 +60,7 @@ Here's an example of how to start OpenHands with the Local Runtime in Headless M
export RUNTIME=local
export SANDBOX_VOLUMES=/my_folder/myproject:/workspace:rw
poetry run python -m openhands.core.main -t "write a bash script that prints hi"
uv run python -m openhands.core.main -t "write a bash script that prints hi"
```
## Use Cases

View File

@@ -0,0 +1,194 @@
---
title: Model Context Protocol (MCP)
description: This page outlines how to configure and use the Model Context Protocol (MCP) in OpenHands, allowing you
to extend the agent's capabilities with custom tools.
---
## Overview
Model Context Protocol (MCP) is a mechanism that allows OpenHands to communicate with external tool servers. These
servers can provide additional functionality to the agent, such as specialized data processing, external API access,
or custom tools. MCP is based on the open standard defined at [modelcontextprotocol.io](https://modelcontextprotocol.io).
## Supported MCPs
OpenHands supports the following MCP transport protocols:
* [Server-Sent Events (SSE)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse)
* [Streamable HTTP (SHTTP)](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http)
* [Standard Input/Output (stdio)](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio)
## How MCP Works
When OpenHands starts, it:
1. Reads the MCP configuration.
2. Connects to any configured SSE and SHTTP servers.
3. Starts any configured stdio servers.
4. Registers the tools provided by these servers with the agent.
The agent can then use these tools just like any built-in tool. When the agent calls an MCP tool:
1. OpenHands routes the call to the appropriate MCP server.
2. The server processes the request and returns a response.
3. OpenHands converts the response to an observation and presents it to the agent.
## Configuration
MCP configuration can be defined in:
* The OpenHands UI in the `Settings > MCP` page.
* The `config.toml` file under the `[mcp]` section if not using the UI.
### Configuration Options
<Tabs>
<Tab title="SSE Servers">
SSE servers are configured using either a string URL or an object with the following properties:
- `url` (required)
- Type: `str`
- Description: The URL of the SSE server.
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication.
</Tab>
<Tab title="SHTTP Servers">
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
- `url` (required)
- Type: `str`
- Description: The URL of the SHTTP server.
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication.
- `timeout` (optional)
- Type: `int`
- Default: `60`
- Range: `1-3600` seconds (1 hour maximum)
- Description: Timeout in seconds for tool execution. This prevents tool calls from hanging indefinitely.
- **Use Cases:**
- **Short timeout (1-30s)**: For lightweight operations like status checks or simple queries.
- **Medium timeout (30-300s)**: For standard processing tasks like data analysis or API calls.
- **Long timeout (300-3600s)**: For heavy operations like file processing, complex calculations, or batch operations.
<Note>
This timeout only applies to individual tool calls, not server connection establishment.
</Note>
</Tab>
<Tab title="Stdio Servers">
<Note>
While stdio servers are supported, [we recommend using MCP proxies](/usage/settings/mcp-settings#configuration-examples) for
better reliability and performance.
</Note>
Stdio servers are configured using an object with the following properties:
- `name` (required)
- Type: `str`
- Description: A unique name for the server.
- `command` (required)
- Type: `str`
- Description: The command to run the server.
- `args` (optional)
- Type: `list of str`
- Default: `[]`
- Description: Command-line arguments to pass to the server.
- `env` (optional)
- Type: `dict of str to str`
- Default: `{}`
- Description: Environment variables to set for the server process.
</Tab>
</Tabs>
#### When to Use Direct Stdio
Direct stdio connections may still be appropriate in these scenarios:
- **Development and testing**: Quick prototyping of MCP servers.
- **Simple, single-use tools**: Tools that don't require high reliability or concurrent access.
- **Local-only environments**: When you don't want to manage additional proxy processes.
### Configuration Examples
<Tabs>
<Tab title="Proxy Servers (SSE/HTTP) - Recommended">
For stdio-based MCP servers, we recommend using MCP proxy tools like
[`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to
HTTP/SSE endpoints.
Start the proxy servers separately:
```bash
# Terminal 1: Filesystem server proxy
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
# Terminal 2: Fetch server proxy
supergateway --stdio "uvx mcp-server-fetch" --port 8081
```
Then configure OpenHands to use the HTTP endpoint:
```toml
[mcp]
# SSE Servers - Recommended approach using proxy tools
sse_servers = [
# Basic SSE server with just a URL
"http://example.com:8080/mcp",
# SuperGateway proxy for fetch server
"http://localhost:8081/sse",
# External MCP service with authentication
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
]
# SHTTP Servers - Modern streamable HTTP transport (recommended)
shttp_servers = [
# Basic SHTTP server with default 60s timeout
"https://api.example.com/mcp/shttp",
# Server with custom timeout for heavy operations
{
url = "https://files.example.com/mcp/shttp",
api_key = "your-api-key",
timeout = 1800 # 30 minutes for large file processing
}
]
```
</Tab>
<Tab title="Direct Stdio Servers">
<Note>
This setup is not Recommended for production.
</Note>
```toml
[mcp]
# Direct stdio servers - use only for development/testing
stdio_servers = [
# Basic stdio server
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
# Stdio server with environment variables
{
name="filesystem",
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/"],
env={
"DEBUG": "true"
}
}
]
```
For production use, we recommend using proxy tools like SuperGateway.
</Tab>
</Tabs>
Other options include:
- **Custom FastAPI/Express servers**: Build your own HTTP wrapper around stdio MCP servers.
- **Docker-based proxies**: Containerized solutions for better isolation.
- **Cloud-hosted MCP services**: Third-party services that provide MCP endpoints.

View File

@@ -1,28 +1,19 @@
---
title: OpenHands Settings
description: Overview of some of the settings available in OpenHands.
title: Secrets Management
description: How to manage secrets in OpenHands.
---
## Openhands Cloud vs Running on Your Own
There are some differences between the settings available in OpenHands Cloud and those available when running OpenHands
on your own:
* [OpenHands Cloud settings](/usage/cloud/cloud-ui#settings)
* [Settings available when running on your own](/usage/how-to/gui-mode#settings)
Refer to these pages for more detailed information.
## Secrets Management
## Overview
OpenHands provides a secrets manager that allows you to securely store and manage sensitive information that can be
accessed by the agent during runtime, such as API keys. These secrets are automatically exported as environment
variables in the agent's runtime environment.
### Accessing the Secrets Manager
## Accessing the Secrets Manager
In the Settings page, navigate to the `Secrets` tab. Here, you'll see a list of all your existing custom secrets.
Navigate to the `Settings > Secrets` page. Here, you'll see a list of all your existing custom secrets.
### Adding a New Secret
## Adding a New Secret
1. Click `Add a new secret`.
2. Fill in the following fields:
- **Name**: A unique identifier for your secret (e.g., `AWS_ACCESS_KEY`). This will be the environment variable name.
@@ -30,7 +21,7 @@ In the Settings page, navigate to the `Secrets` tab. Here, you'll see a list of
- **Description** (optional): A brief description of what the secret is used for, which is also provided to the agent.
3. Click `Add secret` to save.
### Editing a Secret
## Editing a Secret
1. Click the `Edit` button next to the secret you want to modify.
2. You can update the name and description of the secret.
@@ -39,14 +30,13 @@ In the Settings page, navigate to the `Secrets` tab. Here, you'll see a list of
value, delete the secret and create a new one.
</Note>
### Deleting a Secret
## Deleting a Secret
1. Click the `Delete` button next to the secret you want to remove.
2. Select `Confirm` to delete the secret.
### Using Secrets in the Agent
## Using Secrets in the Agent
- All custom secrets are automatically exported as environment variables in the agent's runtime environment.
- You can access them in your code using standard environment variable access methods
(e.g., `os.environ['SECRET_NAME']` in Python).
- Example: If you create a secret named `OPENAI_API_KEY`, you can access it in your code as
`process.env.OPENAI_API_KEY` in JavaScript or `os.environ['OPENAI_API_KEY']` in Python.
- You can access them in your code using standard environment variable access methods. For example, if you create a
secret named `OPENAI_API_KEY`, you can access it in your code as `process.env.OPENAI_API_KEY` in JavaScript or
`os.environ['OPENAI_API_KEY']` in Python.

View File

@@ -38,6 +38,23 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
OpenHands.
### On Linux, Getting ConnectTimeout Error
**Description**
When running on Linux, you might run into the error `ERROR:root:<class 'httpx.ConnectTimeout'>: timed out`.
**Resolution**
If you installed Docker from your distributions package repository (e.g., docker.io on Debian/Ubuntu), be aware that
these packages can sometimes be outdated or include changes that cause compatibility issues. try reinstalling Docker
[using the official instructions](https://docs.docker.com/engine/install/) to ensure you are running a compatible version.
If that does not solve the issue, try incrementally adding the following parameters to the docker run command:
* `--network host`
* `-e SANDBOX_USE_HOST_NETWORK=true`
* `-e DOCKER_HOST_ADDR=127.0.0.1`
### Internal Server Error. Ports are not available
**Description**

View File

@@ -62,18 +62,14 @@ This guide provides step-by-step instructions for running OpenHands on a Windows
npm --version
```
6. **Install Poetry**
6. **Install uv**
- Open PowerShell as Administrator and run:
```powershell
(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | python -
```
- Add Poetry to your PATH:
```powershell
$env:Path += ";$env:APPDATA\Python\Scripts"
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
- Verify installation:
```powershell
poetry --version
uv --version
```
## Step 2: Clone and Set Up OpenHands
@@ -86,7 +82,7 @@ This guide provides step-by-step instructions for running OpenHands on a Windows
2. **Install Dependencies**
```powershell
poetry install
uv sync
```
This will install all required dependencies, including:
@@ -109,7 +105,7 @@ This guide provides step-by-step instructions for running OpenHands on a Windows
```powershell
# Make sure to use PowerShell 7 (pwsh) instead of Windows PowerShell
pwsh
$env:RUNTIME="local"; poetry run uvicorn openhands.server.listen:app --host 0.0.0.0 --port 3000 --reload --reload-exclude "./workspace"
$env:RUNTIME="local"; uv run uvicorn openhands.server.listen:app --host 0.0.0.0 --port 3000 --reload --reload-exclude "./workspace"
```
This will start the OpenHands app using the local runtime with PowerShell integration, available at `localhost:3000`.
@@ -230,7 +226,7 @@ To resolve this issue:
```powershell
pwsh
cd path\to\openhands
$env:RUNTIME="local"; poetry run uvicorn openhands.server.listen:app --host 0.0.0.0 --port 3000 --reload --reload-exclude "./workspace"
$env:RUNTIME="local"; uv run uvicorn openhands.server.listen:app --host 0.0.0.0 --port 3000 --reload --reload-exclude "./workspace"
```
> **Note**: Make sure you're explicitly using `pwsh` (PowerShell 7) and not `powershell` (Windows PowerShell). The command prompt or terminal title should say "PowerShell 7" rather than just "Windows PowerShell".

40
enterprise/Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
ARG OPENHANDS_VERSION=latest
ARG BASE="ghcr.io/all-hands-ai/openhands"
FROM ${BASE}:${OPENHANDS_VERSION}
# Datadog labels
LABEL com.datadoghq.tags.service="deploy"
LABEL com.datadoghq.tags.env="${DD_ENV}"
# Install Node.js v20+ and npm (which includes npx)
# Apply security updates to fix CVEs
RUN apt-get update && \
apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
apt-get install -y jq gettext && \
# Apply security updates for packages with available fixes
apt-get upgrade -y \
libc-bin \
libc6 \
libgnutls30 \
libsqlite3-0 \
perl-base && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Python packages with security fixes
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace posthog "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
# Update packages with known CVE fixes
pip install --upgrade \
"mcp>=1.10.0" \
"pillow>=11.3.0"
WORKDIR /app
COPY enterprise .
RUN chown -R openhands:openhands /app && chmod -R 770 /app
USER openhands
# Command will be overridden by Kubernetes deployment template
CMD ["uvicorn", "saas_server:app", "--host", "0.0.0.0", "--port", "3000"]

89
enterprise/LICENSE Normal file
View File

@@ -0,0 +1,89 @@
# PolyForm Free Trial License 1.0.0
## Acceptance
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
## Copyright License
The licensor grants you a copyright license for the software
to do everything you might do with the software that would
otherwise infringe the licensor's copyright in it for any
permitted purpose. However, you may only make changes or
new works based on the software according to [Changes and New
Works License](#changes-and-new-works-license), and you may
not distribute copies of the software.
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## Free Trial
Use of the software for more than 30 days per calendar year is not allowed without a commercial license.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
If you violate any of these terms, or do anything with the
software not covered by your licenses, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.

42
enterprise/Makefile Normal file
View File

@@ -0,0 +1,42 @@
BACKEND_HOST ?= "127.0.0.1"
BACKEND_PORT = 3000
BACKEND_HOST_PORT = "$(BACKEND_HOST):$(BACKEND_PORT)"
FRONTEND_PORT = 3001
OPENHANDS_PATH ?= "../../OpenHands"
OPENHANDS := $(OPENHANDS_PATH)
OPENHANDS_FRONTEND_PATH = $(OPENHANDS)/frontend/build
# ANSI color codes
GREEN=$(shell tput -Txterm setaf 2)
YELLOW=$(shell tput -Txterm setaf 3)
RED=$(shell tput -Txterm setaf 1)
BLUE=$(shell tput -Txterm setaf 6)
RESET=$(shell tput -Txterm sgr0)
build:
@uv sync
@cd $(OPENHANDS) && $(MAKE) build
_run_setup:
@echo "$(YELLOW)Starting backend server...$(RESET)"
@cd app && FRONTEND_DIRECTORY=$(OPENHANDS_FRONTEND_PATH) uv run uvicorn saas_server:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) &
@echo "$(YELLOW)Waiting for the backend to start...$(RESET)"
@until nc -z localhost $(BACKEND_PORT); do sleep 0.1; done
@echo "$(GREEN)Backend started successfully.$(RESET)"
run:
@echo "$(YELLOW)Running the app...$(RESET)"
@$(MAKE) -s _run_setup
@cd $(OPENHANDS) && $(MAKE) -s start-frontend
@echo "$(GREEN)Application started successfully.$(RESET)"
# Start backend
start-backend:
@echo "$(YELLOW)Starting backend...$(RESET)"
@echo "$(OPENHANDS_FRONTEND_PATH)"
@cd app && FRONTEND_DIRECTORY=$(OPENHANDS_FRONTEND_PATH) uv run uvicorn saas_server:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) --reload-dir $(OPENHANDS_PATH) --reload --reload-dir ./ --reload-exclude "./workspace"
lint:
@uv run pre-commit run --all-files --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml

56
enterprise/README.md Normal file
View File

@@ -0,0 +1,56 @@
# OpenHands Enterprise Server
> [!WARNING]
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.all-hands.dev/contact).
> [!WARNING]
> This is a work in progress and may contain bugs, incomplete features, or breaking changes.
This directory contains the enterprise server used by [OpenHands Cloud](https://github.com/All-Hands-AI/OpenHands-Cloud/). The official, public version of OpenHands Cloud is available at
[app.all-hands.dev](https://app.all-hands.dev).
You may also want to check out the MIT-licensed [OpenHands](https://github.com/All-Hands-AI/OpenHands)
## Extension of OpenHands (OSS)
The code in `/enterprise` directory builds on top of open source (OSS) code, extending its functionality. The enterprise code is entangled with the OSS code in two ways
- Enterprise stacks on top of OSS. For example, the middleware in enterprise is stacked right on top of the middlewares in OSS. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
- Enterprise overrides the implementation in OSS (only one is present at a time). For example, the server config SaasServerConfig which overrides [`ServerConfig`](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/server/config/server_config.py#L8) on OSS. This is done through dynamic imports ([see here](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
Key areas that change on `SAAS` are
- Authentication
- User settings
- etc
### Authentication
| Aspect | OSS | Enterprise |
| ------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The Github app provides a short-lived access token and refresh token |
| **Token Storage** | PAT is stored in **Settings** | Token is stored in **GithubTokenManager** (a file store in our backend) |
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during oauth, so subsequent requests with the cookie can be considered authenticated |
Note that in the future, authentication will happen via keycloak. All modifications for authentication will happen in enterprise.
### GitHub Service
The github service is responsible for interacting with Github APIs. As a consequence, it uses the user's token and refreshes it if need be
| Aspect | OSS | Enterprise |
| ------------------------- | -------------------------------------- | ---------------------------------------------- |
| **Class used** | `GitHubService` | `SaaSGitHubService` |
| **Token used** | User's PAT fetched from `Settings` | User's token fetched from `GitHubTokenManager` |
| **Refresh functionality** | **N/A**; user provides PAT for the app | Uses the `GitHubTokenManager` to refresh |
NOTE: in the future we will simply replace the `GithubTokenManager` with keycloak. The `SaaSGithubService` should interact with keycloack instead.
# Areas that are BRITTLE!
## User ID vs User Token
- On OSS, the entire APP revolves around the Github token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
Note that introducing Github User ID on OSS, for instance, will cause large breakages.

1
enterprise/__init__.py Normal file
View File

@@ -0,0 +1 @@
# App package for OpenHands

79
enterprise/alembic.ini Normal file
View File

@@ -0,0 +1,79 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = DEBUG
handlers = console
qualname =
[logger_sqlalchemy]
level = DEBUG
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = DEBUG
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: docs/modules/python
files: ^enterprise/
- id: end-of-file-fixer
exclude: docs/modules/python
files: ^enterprise/
- id: check-yaml
files: ^enterprise/
- id: debug-statements
files: ^enterprise/
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.24.1
hooks:
- id: validate-pyproject
types: [toml]
additional_dependencies: ["validate-pyproject-schema-store[all]"]
files: ^enterprise/pyproject\.toml$
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.12
hooks:
# Run the linter.
- id: ruff
entry: ruff check --config enterprise/dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
args: [--fix]
files: ^enterprise/
# Run the formatter.
- id: ruff-format
entry: ruff format --config enterprise/dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
files: ^enterprise/
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.17.1
hooks:
- id: mypy
additional_dependencies:
- types-requests
- types-setuptools
- types-pyyaml
- types-toml
- types-redis
- lxml
# OpenHands package in repo root
- ./
- stripe==11.5.0
- pygithub==2.6.1
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file enterprise/dev_config/python/mypy.ini enterprise/
always_run: true
pass_filenames: false
files: ^enterprise/

View File

@@ -0,0 +1,17 @@
[mypy]
warn_unused_configs = True
ignore_missing_imports = True
check_untyped_defs = True
explicit_package_bases = True
warn_unreachable = True
warn_redundant_casts = True
no_implicit_optional = True
strict_optional = True
disable_error_code = type-abstract
exclude = (^enterprise/migrations/.*)
[mypy-enterprise.tests.unit.test_auth_routes.*]
disable_error_code = union-attr
[mypy-enterprise.sync.install_gitlab_webhooks.*]
disable_error_code = redundant-cast

View File

@@ -0,0 +1,31 @@
[lint]
select = [
"E",
"W",
"F",
"I",
"Q",
"B",
]
ignore = [
"E501",
"B003",
"B007",
"B008", # Allow function calls in argument defaults (FastAPI Query pattern)
"B009",
"B010",
"B904",
"B018",
]
exclude = [
"app/migrations/*"
]
[lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "single"
[format]
quote-style = "single"

View File

@@ -0,0 +1,47 @@
import os
import posthog
from openhands.core.logger import openhands_logger as logger
# Initialize PostHog
posthog.api_key = os.environ.get('POSTHOG_CLIENT_KEY', 'phc_placeholder')
posthog.host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
# Log PostHog configuration with masked API key for security
api_key = posthog.api_key
if api_key and len(api_key) > 8:
masked_key = f'{api_key[:4]}...{api_key[-4:]}'
else:
masked_key = 'not_set_or_too_short'
logger.info('posthog_configuration', extra={'posthog_api_key_masked': masked_key})
# Global toggle for the experiment manager
ENABLE_EXPERIMENT_MANAGER = (
os.environ.get('ENABLE_EXPERIMENT_MANAGER', 'false').lower() == 'true'
)
# Get the current experiment type from environment variable
# If None, no experiment is running
EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT = os.environ.get(
'EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT', ''
)
# System prompt experiment toggle
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT = os.environ.get(
'EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT', ''
)
EXPERIMENT_CLAUDE4_VS_GPT5 = os.environ.get('EXPERIMENT_CLAUDE4_VS_GPT5', '')
EXPERIMENT_CONDENSER_MAX_STEP = os.environ.get('EXPERIMENT_CONDENSER_MAX_STEP', '')
logger.info(
'experiment_manager:run_conversation_variant_test:experiment_config',
extra={
'enable_experiment_manager': ENABLE_EXPERIMENT_MANAGER,
'experiment_litellm_default_model_experiment': EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT,
'experiment_system_prompt_experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
'experiment_claude4_vs_gpt5_experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
'experiment_condenser_max_step': EXPERIMENT_CONDENSER_MAX_STEP,
},
)

View File

@@ -0,0 +1,89 @@
from experiments.constants import (
ENABLE_EXPERIMENT_MANAGER,
)
from experiments.experiment_versions import (
handle_condenser_max_step_experiment,
handle_system_prompt_experiment,
)
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.experiments.experiment_manager import ExperimentManager
from openhands.server.session.conversation_init_data import ConversationInitData
class SaaSExperimentManager(ExperimentManager):
@staticmethod
def run_conversation_variant_test(
user_id, conversation_id, conversation_settings
) -> ConversationInitData:
"""
Run conversation variant test and potentially modify the conversation settings
based on the PostHog feature flags.
Args:
user_id: The user ID
conversation_id: The conversation ID
conversation_settings: The conversation settings that may include convo_id and llm_model
Returns:
The modified conversation settings
"""
logger.debug(
'experiment_manager:run_conversation_variant_test:started',
extra={'user_id': user_id},
)
# Skip all experiment processing if the experiment manager is disabled
if not ENABLE_EXPERIMENT_MANAGER:
logger.info(
'experiment_manager:run_conversation_variant_test:skipped',
extra={'reason': 'experiment_manager_disabled'},
)
return conversation_settings
# Apply conversation-scoped experiments
conversation_settings = handle_condenser_max_step_experiment(
user_id, conversation_id, conversation_settings
)
return conversation_settings
@staticmethod
def run_config_variant_test(
user_id: str | None, conversation_id: str, config: OpenHandsConfig
) -> OpenHandsConfig:
"""
Run agent config variant test and potentially modify the OpenHands config
based on the current experiment type and PostHog feature flags.
Args:
user_id: The user ID
conversation_id: The conversation ID
config: The OpenHands configuration
Returns:
The modified OpenHands configuration
"""
logger.info(
'experiment_manager:run_config_variant_test:started',
extra={'user_id': user_id},
)
# Skip all experiment processing if the experiment manager is disabled
if not ENABLE_EXPERIMENT_MANAGER:
logger.info(
'experiment_manager:run_config_variant_test:skipped',
extra={'reason': 'experiment_manager_disabled'},
)
return config
# Pass the entire OpenHands config to the system prompt experiment
# Let the experiment handler directly modify the config as needed
modified_config = handle_system_prompt_experiment(
user_id, conversation_id, config
)
# Condenser max step experiment is applied via conversation variant test,
# not config variant test. Return modified config from system prompt only.
return modified_config

View File

@@ -0,0 +1,107 @@
"""
LiteLLM model experiment handler.
This module contains the handler for the LiteLLM model experiment.
"""
import posthog
from experiments.constants import EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT
from server.constants import (
IS_FEATURE_ENV,
build_litellm_proxy_model_path,
get_default_litellm_model,
)
from openhands.core.logger import openhands_logger as logger
def handle_litellm_default_model_experiment(
user_id, conversation_id, conversation_settings
):
"""
Handle the LiteLLM model experiment.
Args:
user_id: The user ID
conversation_id: The conversation ID
conversation_settings: The conversation settings
Returns:
Modified conversation settings
"""
# No-op if the specific experiment is not enabled
if not EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT:
logger.info(
'experiment_manager:ab_testing:skipped',
extra={
'convo_id': conversation_id,
'reason': 'experiment_not_enabled',
'experiment': EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT,
},
)
return conversation_settings
# Use experiment name as the flag key
try:
enabled_variant = posthog.get_feature_flag(
EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT, conversation_id
)
except Exception as e:
logger.error(
'experiment_manager:get_feature_flag:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT,
'error': str(e),
},
)
return conversation_settings
# Log the experiment event
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
try:
posthog.capture(
distinct_id=posthog_user_id,
event='model_set',
properties={
'conversation_id': conversation_id,
'variant': enabled_variant,
'original_user_id': user_id,
'is_feature_env': IS_FEATURE_ENV,
},
)
except Exception as e:
logger.error(
'experiment_manager:posthog_capture:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT,
'error': str(e),
},
)
# Continue execution as this is not critical
logger.info(
'posthog_capture',
extra={
'event': 'model_set',
'posthog_user_id': posthog_user_id,
'is_feature_env': IS_FEATURE_ENV,
'conversation_id': conversation_id,
'variant': enabled_variant,
},
)
# Set the model based on the feature flag variant
if enabled_variant == 'claude37':
# Use the shared utility to construct the LiteLLM proxy model path
model = build_litellm_proxy_model_path('claude-3-7-sonnet-20250219')
# Update the conversation settings with the selected model
conversation_settings.llm_model = model
else:
# Update the conversation settings with the default model for the current version
conversation_settings.llm_model = get_default_litellm_model()
return conversation_settings

View File

@@ -0,0 +1,181 @@
"""
System prompt experiment handler.
This module contains the handler for the system prompt experiment that uses
the PostHog variant as the system prompt filename.
"""
import copy
import posthog
from experiments.constants import EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT
from server.constants import IS_FEATURE_ENV
from storage.experiment_assignment_store import ExperimentAssignmentStore
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
def _get_system_prompt_variant(user_id, conversation_id):
"""
Get the system prompt variant for the experiment.
Args:
user_id: The user ID
conversation_id: The conversation ID
Returns:
str or None: The PostHog variant name or None if experiment is not enabled or error occurs
"""
# No-op if the specific experiment is not enabled
if not EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
logger.info(
'experiment_manager_002:ab_testing:skipped',
extra={
'convo_id': conversation_id,
'reason': 'experiment_not_enabled',
'experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
},
)
return None
# Use experiment name as the flag key
try:
enabled_variant = posthog.get_feature_flag(
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT, conversation_id
)
except Exception as e:
logger.error(
'experiment_manager:get_feature_flag:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
'error': str(e),
},
)
return None
# Store the experiment assignment in the database
try:
experiment_store = ExperimentAssignmentStore()
experiment_store.update_experiment_variant(
conversation_id=conversation_id,
experiment_name='system_prompt_experiment',
variant=enabled_variant,
)
except Exception as e:
logger.error(
'experiment_manager:store_assignment:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
'variant': enabled_variant,
'error': str(e),
},
)
# Fail the experiment if we cannot track the splits - results would not be explainable
return None
# Log the experiment event
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
try:
posthog.capture(
distinct_id=posthog_user_id,
event='system_prompt_set',
properties={
'conversation_id': conversation_id,
'variant': enabled_variant,
'original_user_id': user_id,
'is_feature_env': IS_FEATURE_ENV,
},
)
except Exception as e:
logger.error(
'experiment_manager:posthog_capture:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
'error': str(e),
},
)
# Continue execution as this is not critical
logger.info(
'posthog_capture',
extra={
'event': 'system_prompt_set',
'posthog_user_id': posthog_user_id,
'is_feature_env': IS_FEATURE_ENV,
'conversation_id': conversation_id,
'variant': enabled_variant,
},
)
return enabled_variant
def handle_system_prompt_experiment(
user_id, conversation_id, config: OpenHandsConfig
) -> OpenHandsConfig:
"""
Handle the system prompt experiment for OpenHands config.
Args:
user_id: The user ID
conversation_id: The conversation ID
config: The OpenHands configuration
Returns:
Modified OpenHands configuration
"""
enabled_variant = _get_system_prompt_variant(user_id, conversation_id)
# If variant is None, experiment is not enabled or there was an error
if enabled_variant is None:
return config
# Deep copy the config to avoid modifying the original
modified_config = copy.deepcopy(config)
# Set the system prompt filename based on the variant
if enabled_variant == 'control':
# Use the long-horizon system prompt for the control variant
agent_config = modified_config.get_agent_config(modified_config.default_agent)
agent_config.system_prompt_filename = 'system_prompt_long_horizon.j2'
agent_config.enable_plan_mode = True
elif enabled_variant == 'interactive':
modified_config.get_agent_config(
modified_config.default_agent
).system_prompt_filename = 'system_prompt_interactive.j2'
elif enabled_variant == 'no_tools':
modified_config.get_agent_config(
modified_config.default_agent
).system_prompt_filename = 'system_prompt.j2'
else:
logger.error(
'system_prompt_experiment:unknown_variant',
extra={
'user_id': user_id,
'convo_id': conversation_id,
'variant': enabled_variant,
'reason': 'no explicit mapping; returning original config',
},
)
return config
# Log which prompt is being used
logger.info(
'system_prompt_experiment:prompt_selected',
extra={
'user_id': user_id,
'convo_id': conversation_id,
'system_prompt_filename': modified_config.get_agent_config(
modified_config.default_agent
).system_prompt_filename,
'variant': enabled_variant,
},
)
return modified_config

View File

@@ -0,0 +1,137 @@
"""
LiteLLM model experiment handler.
This module contains the handler for the LiteLLM model experiment.
"""
import posthog
from experiments.constants import EXPERIMENT_CLAUDE4_VS_GPT5
from server.constants import (
IS_FEATURE_ENV,
build_litellm_proxy_model_path,
get_default_litellm_model,
)
from storage.experiment_assignment_store import ExperimentAssignmentStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.session.conversation_init_data import ConversationInitData
def _get_model_variant(user_id: str | None, conversation_id: str) -> str | None:
if not EXPERIMENT_CLAUDE4_VS_GPT5:
logger.info(
'experiment_manager:ab_testing:skipped',
extra={
'convo_id': conversation_id,
'reason': 'experiment_not_enabled',
'experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
},
)
return None
try:
enabled_variant = posthog.get_feature_flag(
EXPERIMENT_CLAUDE4_VS_GPT5, conversation_id
)
except Exception as e:
logger.error(
'experiment_manager:get_feature_flag:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
'error': str(e),
},
)
return None
# Store the experiment assignment in the database
try:
experiment_store = ExperimentAssignmentStore()
experiment_store.update_experiment_variant(
conversation_id=conversation_id,
experiment_name='claude4_vs_gpt5_experiment',
variant=enabled_variant,
)
except Exception as e:
logger.error(
'experiment_manager:store_assignment:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
'variant': enabled_variant,
'error': str(e),
},
)
# Fail the experiment if we cannot track the splits - results would not be explainable
return None
# Log the experiment event
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
try:
posthog.capture(
distinct_id=posthog_user_id,
event='claude4_or_gpt5_set',
properties={
'conversation_id': conversation_id,
'variant': enabled_variant,
'original_user_id': user_id,
'is_feature_env': IS_FEATURE_ENV,
},
)
except Exception as e:
logger.error(
'experiment_manager:posthog_capture:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
'error': str(e),
},
)
# Continue execution as this is not critical
logger.info(
'posthog_capture',
extra={
'event': 'claude4_or_gpt5_set',
'posthog_user_id': posthog_user_id,
'is_feature_env': IS_FEATURE_ENV,
'conversation_id': conversation_id,
'variant': enabled_variant,
},
)
return enabled_variant
def handle_claude4_vs_gpt5_experiment(
user_id: str | None,
conversation_id: str,
conversation_settings: ConversationInitData,
) -> ConversationInitData:
"""
Handle the LiteLLM model experiment.
Args:
user_id: The user ID
conversation_id: The conversation ID
conversation_settings: The conversation settings
Returns:
Modified conversation settings
"""
enabled_variant = _get_model_variant(user_id, conversation_id)
if not enabled_variant:
return conversation_settings
# Set the model based on the feature flag variant
if enabled_variant == 'gpt5':
model = build_litellm_proxy_model_path('gpt-5-2025-08-07')
conversation_settings.llm_model = model
else:
conversation_settings.llm_model = get_default_litellm_model()
return conversation_settings

View File

@@ -0,0 +1,192 @@
"""
Condenser max step experiment handler.
This module contains the handler for the condenser max step experiment that tests
different max_size values for the condenser configuration.
"""
import posthog
from experiments.constants import EXPERIMENT_CONDENSER_MAX_STEP
from server.constants import IS_FEATURE_ENV
from storage.experiment_assignment_store import ExperimentAssignmentStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.session.conversation_init_data import ConversationInitData
def _get_condenser_max_step_variant(user_id, conversation_id):
"""
Get the condenser max step variant for the experiment.
Args:
user_id: The user ID
conversation_id: The conversation ID
Returns:
str or None: The PostHog variant name or None if experiment is not enabled or error occurs
"""
# No-op if the specific experiment is not enabled
if not EXPERIMENT_CONDENSER_MAX_STEP:
logger.info(
'experiment_manager_004:ab_testing:skipped',
extra={
'convo_id': conversation_id,
'reason': 'experiment_not_enabled',
'experiment': EXPERIMENT_CONDENSER_MAX_STEP,
},
)
return None
# Use experiment name as the flag key
try:
enabled_variant = posthog.get_feature_flag(
EXPERIMENT_CONDENSER_MAX_STEP, conversation_id
)
except Exception as e:
logger.error(
'experiment_manager:get_feature_flag:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_CONDENSER_MAX_STEP,
'error': str(e),
},
)
return None
# Store the experiment assignment in the database
try:
experiment_store = ExperimentAssignmentStore()
experiment_store.update_experiment_variant(
conversation_id=conversation_id,
experiment_name='condenser_max_step_experiment',
variant=enabled_variant,
)
except Exception as e:
logger.error(
'experiment_manager:store_assignment:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_CONDENSER_MAX_STEP,
'variant': enabled_variant,
'error': str(e),
},
)
# Fail the experiment if we cannot track the splits - results would not be explainable
return None
# Log the experiment event
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
try:
posthog.capture(
distinct_id=posthog_user_id,
event='condenser_max_step_set',
properties={
'conversation_id': conversation_id,
'variant': enabled_variant,
'original_user_id': user_id,
'is_feature_env': IS_FEATURE_ENV,
},
)
except Exception as e:
logger.error(
'experiment_manager:posthog_capture:failed',
extra={
'convo_id': conversation_id,
'experiment': EXPERIMENT_CONDENSER_MAX_STEP,
'error': str(e),
},
)
# Continue execution as this is not critical
logger.info(
'posthog_capture',
extra={
'event': 'condenser_max_step_set',
'posthog_user_id': posthog_user_id,
'is_feature_env': IS_FEATURE_ENV,
'conversation_id': conversation_id,
'variant': enabled_variant,
},
)
return enabled_variant
def handle_condenser_max_step_experiment(
user_id: str | None,
conversation_id: str,
conversation_settings: ConversationInitData,
) -> ConversationInitData:
"""
Handle the condenser max step experiment for conversation settings.
We should not modify persistent user settings. Instead, apply the experiment
variant to the conversation's in-memory settings object for this session only.
Variants:
- control -> condenser_max_size = 120
- treatment -> condenser_max_size = 80
Returns the (potentially) modified conversation_settings.
"""
enabled_variant = _get_condenser_max_step_variant(user_id, conversation_id)
if enabled_variant is None:
return conversation_settings
if enabled_variant == 'control':
condenser_max_size = 120
elif enabled_variant == 'treatment':
condenser_max_size = 80
else:
logger.error(
'condenser_max_step_experiment:unknown_variant',
extra={
'user_id': user_id,
'convo_id': conversation_id,
'variant': enabled_variant,
'reason': 'unknown variant; returning original conversation settings',
},
)
return conversation_settings
try:
# Apply the variant to this conversation only; do not persist to DB.
# Not all OpenHands versions expose `condenser_max_size` on settings.
if hasattr(conversation_settings, 'condenser_max_size'):
conversation_settings.condenser_max_size = condenser_max_size
logger.info(
'condenser_max_step_experiment:conversation_settings_applied',
extra={
'user_id': user_id,
'convo_id': conversation_id,
'variant': enabled_variant,
'condenser_max_size': condenser_max_size,
},
)
else:
logger.warning(
'condenser_max_step_experiment:field_missing_on_settings',
extra={
'user_id': user_id,
'convo_id': conversation_id,
'variant': enabled_variant,
'reason': 'condenser_max_size not present on ConversationInitData',
},
)
except Exception as e:
logger.error(
'condenser_max_step_experiment:apply_failed',
extra={
'user_id': user_id,
'convo_id': conversation_id,
'variant': enabled_variant,
'error': str(e),
},
)
return conversation_settings
return conversation_settings

View File

@@ -0,0 +1,25 @@
"""
Experiment versions package.
This package contains handlers for different experiment versions.
"""
from experiments.experiment_versions._001_litellm_default_model_experiment import (
handle_litellm_default_model_experiment,
)
from experiments.experiment_versions._002_system_prompt_experiment import (
handle_system_prompt_experiment,
)
from experiments.experiment_versions._003_llm_claude4_vs_gpt5_experiment import (
handle_claude4_vs_gpt5_experiment,
)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment,
)
__all__ = [
'handle_litellm_default_model_experiment',
'handle_system_prompt_experiment',
'handle_claude4_vs_gpt5_experiment',
'handle_condenser_max_step_experiment',
]

View File

View File

@@ -0,0 +1,70 @@
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.bitbucket.bitbucket_service import BitBucketService
from openhands.integrations.service_types import ProviderType
class SaaSBitBucketService(BitBucketService):
def __init__(
self,
user_id: str | None = None,
external_auth_token: SecretStr | None = None,
external_auth_id: str | None = None,
token: SecretStr | None = None,
external_token_manager: bool = False,
base_domain: str | None = None,
):
logger.info(
f'SaaSBitBucketService created with user_id {user_id}, external_auth_id {external_auth_id}, external_auth_token {"set" if external_auth_token else "None"}, bitbucket_token {"set" if token else "None"}, external_token_manager {external_token_manager}'
)
super().__init__(
user_id=user_id,
external_auth_token=external_auth_token,
external_auth_id=external_auth_id,
token=token,
external_token_manager=external_token_manager,
base_domain=base_domain,
)
self.external_auth_token = external_auth_token
self.external_auth_id = external_auth_id
self.token_manager = TokenManager(external=external_token_manager)
async def get_latest_token(self) -> SecretStr | None:
bitbucket_token = None
if self.external_auth_token:
bitbucket_token = SecretStr(
await self.token_manager.get_idp_token(
self.external_auth_token.get_secret_value(),
idp=ProviderType.BITBUCKET,
)
)
logger.debug(
f'Got BitBucket token {bitbucket_token} from access token: {self.external_auth_token}'
)
elif self.external_auth_id:
offline_token = await self.token_manager.load_offline_token(
self.external_auth_id
)
bitbucket_token = SecretStr(
await self.token_manager.get_idp_token_from_offline_token(
offline_token, ProviderType.BITBUCKET
)
)
logger.info(
f'Got BitBucket token {bitbucket_token.get_secret_value()} from external auth user ID: {self.external_auth_id}'
)
elif self.user_id:
bitbucket_token = SecretStr(
await self.token_manager.get_idp_token_from_idp_user_id(
self.user_id, ProviderType.BITBUCKET
)
)
logger.debug(
f'Got BitBucket token {bitbucket_token} from user ID: {self.user_id}'
)
else:
logger.warning('external_auth_token and user_id not set!')
return bitbucket_token

View File

@@ -0,0 +1,692 @@
import base64
import json
import os
import re
from datetime import datetime
from enum import Enum
from typing import Any
from github import Github, GithubIntegration
from integrations.github.github_view import (
GithubIssue,
)
from integrations.github.queries import PR_QUERY_BY_NODE_ID
from integrations.models import Message
from integrations.types import PRStatus, ResolverViewInterface
from integrations.utils import HOST
from pydantic import SecretStr
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from storage.openhands_pr import OpenhandsPR
from storage.openhands_pr_store import OpenhandsPRStore
from openhands.core.config import load_openhands_config
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.service_types import ProviderType
from openhands.storage import get_file_store
from openhands.storage.locations import get_conversation_dir
config = load_openhands_config()
file_store = get_file_store(config.file_store, config.file_store_path)
COLLECT_GITHUB_INTERACTIONS = (
os.getenv('COLLECT_GITHUB_INTERACTIONS', 'false') == 'true'
)
class TriggerType(str, Enum):
ISSUE_LABEL = 'issue-label'
ISSUE_COMMENT = 'issue-coment'
PR_COMMENT_MACRO = 'label'
INLINE_PR_COMMENT_MACRO = 'inline-label'
class GitHubDataCollector:
"""
Saves data on Cloud Resolver Interactions
1. We always save
- Resolver trigger (comment or label)
- Metadata (who started the job, repo name, issue number)
2. We save data for the type of interaction
a. For labelled issues, we save
- {conversation_dir}/{conversation_id}/github_data/issue__{repo_name}_{issue_number}.json
- issue number
- trigger
- metadata
- body
- title
- comments
- {conversation_dir}/{conversation_id}/github_data/pr__{repo_name}_{pr_number}.json
- pr_number
- metadata
- body
- title
- commits/authors
3. For all PRs that were opened with the resolver, we save
- github_data/prs/{repo_name}_{pr_number}/data.json
- pr_number
- title
- body
- commits/authors
- code diffs
- merge status (either merged/closed)
"""
def __init__(self):
self.file_store = file_store
self.issues_path = 'github_data/issue-{}-{}/data.json'
self.matching_pr_path = 'github_data/pr-{}-{}/data.json'
# self.full_saved_pr_path = 'github_data/prs/{}-{}/data.json'
self.full_saved_pr_path = 'prs/github/{}-{}/data.json'
self.github_integration = GithubIntegration(
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
)
self.conversation_id = None
async def _get_repo_node_id(self, repo_id: str, gh_client) -> str:
"""
Get the new GitHub GraphQL node ID for a repository using the GitHub client.
Args:
repo_id: Numeric repository ID as string (e.g., "123456789")
gh_client: SaaSGitHubService client with authentication
Returns:
New format node ID for GraphQL queries (e.g., "R_kgDOLfkiww")
"""
try:
return await gh_client.get_repository_node_id(repo_id)
except Exception:
# Fallback to old format if REST API fails
node_string = f'010:Repository{repo_id}'
return base64.b64encode(node_string.encode()).decode()
def _create_file_name(
self, path: str, repo_id: str, number: int, conversation_id: str | None
):
suffix = path.format(repo_id, number)
if conversation_id:
return f'{get_conversation_dir(conversation_id)}{suffix}'
return suffix
def _get_installation_access_token(self, installation_id: str) -> str:
token_data = self.github_integration.get_access_token(
installation_id # type: ignore[arg-type]
)
return token_data.token
def _check_openhands_author(self, name, login) -> bool:
return (
name == 'openhands'
or login == 'openhands'
or login == 'openhands-agent'
or login == 'openhands-ai'
or login == 'openhands-staging'
or login == 'openhands-exp'
or (login and 'openhands' in login.lower())
)
def _get_issue_comments(
self, installation_id: str, repo_name: str, issue_number: int, conversation_id
) -> list[dict[str, Any]]:
"""
Retrieve all comments from an issue until a comment with conversation_id is found
"""
try:
installation_token = self._get_installation_access_token(installation_id)
with Github(installation_token) as github_client:
repo = github_client.get_repo(repo_name)
issue = repo.get_issue(issue_number)
comments = []
for comment in issue.get_comments():
comment_data = {
'id': comment.id,
'body': comment.body,
'created_at': comment.created_at.isoformat(),
'user': comment.user.login,
}
# If we find a comment containing conversation_id, stop collecting comments
if conversation_id in comment.body:
break
comments.append(comment_data)
return comments
except Exception:
return []
def _save_data(self, path: str, data: dict[str, Any]):
"""Save data to a path"""
self.file_store.write(path, json.dumps(data))
def _save_issue(
self,
github_view: GithubIssue,
trigger_type: TriggerType,
) -> None:
"""
Save issue data when it's labeled with openhands
1. Save under {conversation_dir}/{conversation_id}/github_data/issue_{issue_number}.json
2. Save issue snapshot (title, body, comments)
3. Save trigger type (label)
4. Save PR opened (if exists, this information comes later when agent has finished its task)
- Save commit shas
- Save author info
5. Was PR merged or closed
"""
conversation_id = github_view.conversation_id
if not conversation_id:
return
issue_number = github_view.issue_number
file_name = self._create_file_name(
path=self.issues_path,
repo_id=github_view.full_repo_name,
number=issue_number,
conversation_id=conversation_id,
)
payload_data = github_view.raw_payload.message.get('payload', {})
isssue_details = payload_data.get('issue', {})
is_repo_private = payload_data.get('repository', {}).get('private', 'true')
title = isssue_details.get('title', '')
body = isssue_details.get('body', '')
# Get comments for the issue
comments = self._get_issue_comments(
github_view.installation_id,
github_view.full_repo_name,
issue_number,
conversation_id,
)
data = {
'trigger': trigger_type,
'metadata': {
'user': github_view.user_info.username,
'repo_name': github_view.full_repo_name,
'is_repo_private': is_repo_private,
'number': issue_number,
},
'contents': {
'title': title,
'body': body,
'comments': comments,
},
}
self._save_data(file_name, data)
logger.info(
f'[Github]: Saved issue #{issue_number} for {github_view.full_repo_name}'
)
def _get_pr_commits(self, installation_id: str, repo_name: str, pr_number: int):
commits = []
installation_token = self._get_installation_access_token(installation_id)
with Github(installation_token) as github_client:
repo = github_client.get_repo(repo_name)
pr = repo.get_pull(pr_number)
for commit in pr.get_commits():
commit_data = {
'sha': commit.sha,
'authors': commit.author.login if commit.author else None,
'committed_date': commit.commit.committer.date.isoformat()
if commit.commit and commit.commit.committer
else None,
}
commits.append(commit_data)
return commits
def _extract_repo_metadata(self, repo_data: dict) -> dict:
"""Extract repository metadata from GraphQL response"""
return {
'name': repo_data.get('name'),
'owner': repo_data.get('owner', {}).get('login'),
'languages': [
lang['name'] for lang in repo_data.get('languages', {}).get('nodes', [])
],
}
def _process_commits_page(self, pr_data: dict, commits: list) -> None:
"""Process commits from a single GraphQL page"""
commit_nodes = pr_data.get('commits', {}).get('nodes', [])
for commit_node in commit_nodes:
commit = commit_node['commit']
author_info = commit.get('author', {})
commit_data = {
'sha': commit['oid'],
'message': commit['message'],
'committed_date': commit.get('committedDate'),
'author': {
'name': author_info.get('name'),
'email': author_info.get('email'),
'github_login': author_info.get('user', {}).get('login'),
},
'stats': {
'additions': commit.get('additions', 0),
'deletions': commit.get('deletions', 0),
'changed_files': commit.get('changedFiles', 0),
},
}
commits.append(commit_data)
def _process_pr_comments_page(self, pr_data: dict, pr_comments: list) -> None:
"""Process PR comments from a single GraphQL page"""
comment_nodes = pr_data.get('comments', {}).get('nodes', [])
for comment in comment_nodes:
comment_data = {
'author': comment.get('author', {}).get('login'),
'body': comment.get('body'),
'created_at': comment.get('createdAt'),
'type': 'pr_comment',
}
pr_comments.append(comment_data)
def _process_review_comments_page(
self, pr_data: dict, review_comments: list
) -> None:
"""Process reviews and review comments from a single GraphQL page"""
review_nodes = pr_data.get('reviews', {}).get('nodes', [])
for review in review_nodes:
# Add the review itself if it has a body
if review.get('body', '').strip():
review_data = {
'author': review.get('author', {}).get('login'),
'body': review.get('body'),
'created_at': review.get('createdAt'),
'state': review.get('state'),
'type': 'review',
}
review_comments.append(review_data)
# Add individual review comments
review_comment_nodes = review.get('comments', {}).get('nodes', [])
for review_comment in review_comment_nodes:
review_comment_data = {
'author': review_comment.get('author', {}).get('login'),
'body': review_comment.get('body'),
'created_at': review_comment.get('createdAt'),
'type': 'review_comment',
}
review_comments.append(review_comment_data)
def _count_openhands_activity(
self, commits: list, review_comments: list, pr_comments: list
) -> tuple[int, int, int]:
"""Count OpenHands commits, review comments, and general PR comments"""
openhands_commit_count = 0
openhands_review_comment_count = 0
openhands_general_comment_count = 0
# Count commits by OpenHands (check both name and login)
for commit in commits:
author = commit.get('author', {})
author_name = author.get('name', '').lower()
author_login = (
author.get('github_login', '').lower()
if author.get('github_login')
else ''
)
if self._check_openhands_author(author_name, author_login):
openhands_commit_count += 1
# Count review comments by OpenHands
for review_comment in review_comments:
author_login = (
review_comment.get('author', '').lower()
if review_comment.get('author')
else ''
)
author_name = '' # Initialize to avoid reference before assignment
if self._check_openhands_author(author_name, author_login):
openhands_review_comment_count += 1
# Count general PR comments by OpenHands
for pr_comment in pr_comments:
author_login = (
pr_comment.get('author', '').lower() if pr_comment.get('author') else ''
)
author_name = '' # Initialize to avoid reference before assignment
if self._check_openhands_author(author_name, author_login):
openhands_general_comment_count += 1
return (
openhands_commit_count,
openhands_review_comment_count,
openhands_general_comment_count,
)
def _build_final_data_structure(
self,
repo_data: dict,
pr_data: dict,
commits: list,
pr_comments: list,
review_comments: list,
openhands_commit_count: int,
openhands_review_comment_count: int,
openhands_general_comment_count: int = 0,
) -> dict:
"""Build the final data structure for JSON storage"""
is_merged = pr_data['merged']
merged_by = None
merge_commit_sha = None
if is_merged:
merged_by = (pr_data.get('mergedBy') or {}).get('login')
merge_commit_sha = (pr_data.get('mergeCommit') or {}).get('oid')
return {
'repo_metadata': self._extract_repo_metadata(repo_data),
'pr_metadata': {
'username': (pr_data.get('author') or {}).get('login'),
'number': pr_data.get('number'),
'title': pr_data.get('title'),
'body': pr_data.get('body'),
'comments': pr_comments,
},
'commits': commits,
'review_comments': review_comments,
'merge_status': {
'merged': pr_data.get('merged'),
'merged_by': merged_by,
'state': pr_data.get('state'),
'merge_commit_sha': merge_commit_sha,
},
'openhands_stats': {
'num_commits': openhands_commit_count,
'num_review_comments': openhands_review_comment_count,
'num_general_comments': openhands_general_comment_count,
'helped_author': openhands_commit_count > 0,
},
}
async def save_full_pr(self, openhands_pr: OpenhandsPR) -> None:
"""
Save PR information including metadata and commit details using GraphQL
Saves:
- Repo metadata (repo name, languages, contributors)
- PR metadata (number, title, body, author, comments)
- Commit information (sha, authors, message, stats)
- Merge status
- Num openhands commits
- Num openhands review comments
"""
pr_number = openhands_pr.pr_number
installation_id = openhands_pr.installation_id
repo_id = openhands_pr.repo_id
# Get installation token and create Github client
# This will fail if the user decides to revoke OpenHands' access to their repo
# In this case, we will simply return when the exception occurs
# This will not lead to infinite loops when processing PRs as we log number of attempts and cap max attempts independently from this
try:
installation_token = self._get_installation_access_token(installation_id)
except Exception as e:
logger.warning(
f'Failed to generate token for {openhands_pr.repo_name}: {e}'
)
return
gh_client = GithubServiceImpl(token=SecretStr(installation_token))
# Get the new format GraphQL node ID
node_id = await self._get_repo_node_id(repo_id, gh_client)
# Initialize data structures
commits: list[dict] = []
pr_comments: list[dict] = []
review_comments: list[dict] = []
pr_data = None
repo_data = None
# Pagination cursors
commits_after = None
comments_after = None
reviews_after = None
# Fetch all data with pagination
while True:
variables = {
'nodeId': node_id,
'pr_number': pr_number,
'commits_after': commits_after,
'comments_after': comments_after,
'reviews_after': reviews_after,
}
try:
result = await gh_client.execute_graphql_query(
PR_QUERY_BY_NODE_ID, variables
)
if not result.get('data', {}).get('node', {}).get('pullRequest'):
break
pr_data = result['data']['node']['pullRequest']
repo_data = result['data']['node']
# Process data from this page using modular methods
self._process_commits_page(pr_data, commits)
self._process_pr_comments_page(pr_data, pr_comments)
self._process_review_comments_page(pr_data, review_comments)
# Check pagination for all three types
has_more_commits = (
pr_data.get('commits', {})
.get('pageInfo', {})
.get('hasNextPage', False)
)
has_more_comments = (
pr_data.get('comments', {})
.get('pageInfo', {})
.get('hasNextPage', False)
)
has_more_reviews = (
pr_data.get('reviews', {})
.get('pageInfo', {})
.get('hasNextPage', False)
)
# Update cursors
if has_more_commits:
commits_after = (
pr_data.get('commits', {}).get('pageInfo', {}).get('endCursor')
)
else:
commits_after = None
if has_more_comments:
comments_after = (
pr_data.get('comments', {}).get('pageInfo', {}).get('endCursor')
)
else:
comments_after = None
if has_more_reviews:
reviews_after = (
pr_data.get('reviews', {}).get('pageInfo', {}).get('endCursor')
)
else:
reviews_after = None
# Continue if there's more data to fetch
if not (has_more_commits or has_more_comments or has_more_reviews):
break
except Exception:
logger.warning('Error fetching PR data', exc_info=True)
return
if not pr_data or not repo_data:
return
# Count OpenHands activity using modular method
(
openhands_commit_count,
openhands_review_comment_count,
openhands_general_comment_count,
) = self._count_openhands_activity(commits, review_comments, pr_comments)
logger.info(
f'[Github]: PR #{pr_number} - OpenHands commits: {openhands_commit_count}, review comments: {openhands_review_comment_count}, general comments: {openhands_general_comment_count}'
)
logger.info(
f'[Github]: PR #{pr_number} - Total collected: {len(commits)} commits, {len(pr_comments)} PR comments, {len(review_comments)} review comments'
)
# Build final data structure using modular method
data = self._build_final_data_structure(
repo_data,
pr_data or {},
commits,
pr_comments,
review_comments,
openhands_commit_count,
openhands_review_comment_count,
openhands_general_comment_count,
)
# Update the OpenhandsPR object with OpenHands statistics
store = OpenhandsPRStore.get_instance()
openhands_helped_author = openhands_commit_count > 0
# Update the PR with OpenHands statistics
update_success = store.update_pr_openhands_stats(
repo_id=repo_id,
pr_number=pr_number,
original_updated_at=openhands_pr.updated_at,
openhands_helped_author=openhands_helped_author,
num_openhands_commits=openhands_commit_count,
num_openhands_review_comments=openhands_review_comment_count,
num_openhands_general_comments=openhands_general_comment_count,
)
if not update_success:
logger.warning(
f'[Github]: Failed to update OpenHands stats for PR #{pr_number} in repo {repo_id} - PR may have been modified concurrently'
)
# Save to file
file_name = self._create_file_name(
path=self.full_saved_pr_path,
repo_id=repo_id,
number=pr_number,
conversation_id=None,
)
self._save_data(file_name, data)
logger.info(
f'[Github]: Saved full PR #{pr_number} for repo {repo_id} with OpenHands stats: commits={openhands_commit_count}, reviews={openhands_review_comment_count}, general_comments={openhands_general_comment_count}, helped={openhands_helped_author}'
)
def _check_for_conversation_url(self, body):
conversation_pattern = re.search(
rf'https://{HOST}/conversations/([a-zA-Z0-9-]+)(?:\s|[.,;!?)]|$)', body
)
if conversation_pattern:
return conversation_pattern.group(1)
return None
def _is_pr_closed_or_merged(self, payload):
"""
Check if PR was closed (regardless of conversation URL)
"""
action = payload.get('action', '')
return action == 'closed' and 'pull_request' in payload
def _track_closed_or_merged_pr(self, payload):
"""
Track PR closed/merged event
"""
repo_id = str(payload['repository']['id'])
pr_number = payload['number']
installation_id = str(payload['installation']['id'])
private = payload['repository']['private']
repo_name = payload['repository']['full_name']
pr_data = payload['pull_request']
# Extract PR metrics
num_reviewers = len(pr_data.get('requested_reviewers', []))
num_commits = pr_data.get('commits', 0)
num_review_comments = pr_data.get('review_comments', 0)
num_general_comments = pr_data.get('comments', 0)
num_changed_files = pr_data.get('changed_files', 0)
num_additions = pr_data.get('additions', 0)
num_deletions = pr_data.get('deletions', 0)
merged = pr_data.get('merged', False)
# Extract closed_at timestamp
# Example: "closed_at":"2025-06-19T21:19:36Z"
closed_at_str = pr_data.get('closed_at')
created_at = pr_data.get('created_at')
closed_at = datetime.fromisoformat(closed_at_str.replace('Z', '+00:00'))
# Determine status based on whether it was merged
status = PRStatus.MERGED if merged else PRStatus.CLOSED
store = OpenhandsPRStore.get_instance()
pr = OpenhandsPR(
repo_name=repo_name,
repo_id=repo_id,
pr_number=pr_number,
status=status,
provider=ProviderType.GITHUB.value,
installation_id=installation_id,
private=private,
num_reviewers=num_reviewers,
num_commits=num_commits,
num_review_comments=num_review_comments,
num_changed_files=num_changed_files,
num_additions=num_additions,
num_deletions=num_deletions,
merged=merged,
created_at=created_at,
closed_at=closed_at,
# These properties will be enriched later
openhands_helped_author=None,
num_openhands_commits=None,
num_openhands_review_comments=None,
num_general_comments=num_general_comments,
)
store.insert_pr(pr)
logger.info(f'Tracked PR {status}: {repo_id}#{pr_number}')
def process_payload(self, message: Message):
if not COLLECT_GITHUB_INTERACTIONS:
return
raw_payload = message.message.get('payload', {})
if self._is_pr_closed_or_merged(raw_payload):
self._track_closed_or_merged_pr(raw_payload)
async def save_data(self, github_view: ResolverViewInterface):
if not COLLECT_GITHUB_INTERACTIONS:
return
return
# TODO: track issue metadata in DB and save comments to filestore

View File

@@ -0,0 +1,344 @@
from types import MappingProxyType
from github import Github, GithubIntegration
from integrations.github.data_collector import GitHubDataCollector
from integrations.github.github_solvability import summarize_issue_solvability
from integrations.github.github_view import (
GithubFactory,
GithubFailingAction,
GithubInlinePRComment,
GithubIssue,
GithubIssueComment,
GithubPRComment,
)
from integrations.manager import Manager
from integrations.models import (
Message,
SourceType,
)
from integrations.types import ResolverViewInterface
from integrations.utils import (
CONVERSATION_URL,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
)
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.utils.async_utils import call_sync_from_async
class GithubManager(Manager):
def __init__(
self, token_manager: TokenManager, data_collector: GitHubDataCollector
):
self.token_manager = token_manager
self.data_collector = data_collector
self.github_integration = GithubIntegration(
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
)
self.jinja_env = Environment(
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'github')
)
def _confirm_incoming_source_type(self, message: Message):
if message.source != SourceType.GITHUB:
raise ValueError(f'Unexpected message source {message.source}')
def _get_full_repo_name(self, repo_obj: dict) -> str:
owner = repo_obj['owner']['login']
repo_name = repo_obj['name']
return f'{owner}/{repo_name}'
def _get_installation_access_token(self, installation_id: str) -> str:
# get_access_token is typed to only accept int, but it can handle str.
token_data = self.github_integration.get_access_token(
installation_id # type: ignore[arg-type]
)
return token_data.token
def _add_reaction(
self, github_view: ResolverViewInterface, reaction: str, installation_token: str
):
"""Add a reaction to the GitHub issue, PR, or comment.
Args:
github_view: The GitHub view object containing issue/PR/comment info
reaction: The reaction to add (e.g. "eyes", "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket")
installation_token: GitHub installation access token for API access
"""
with Github(installation_token) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
# Add reaction based on view type
if isinstance(github_view, GithubInlinePRComment):
pr = repo.get_pull(github_view.issue_number)
inline_comment = pr.get_review_comment(github_view.comment_id)
inline_comment.create_reaction(reaction)
elif isinstance(github_view, (GithubIssueComment, GithubPRComment)):
issue = repo.get_issue(github_view.issue_number)
comment = issue.get_comment(github_view.comment_id)
comment.create_reaction(reaction)
else:
issue = repo.get_issue(github_view.issue_number)
issue.create_reaction(reaction)
def _user_has_write_access_to_repo(
self, installation_id: str, full_repo_name: str, username: str
) -> bool:
"""Check if the user is an owner, collaborator, or member of the repository."""
with self.github_integration.get_github_for_installation(
installation_id, # type: ignore[arg-type]
{},
) as repos:
repository = repos.get_repo(full_repo_name)
# Check if the user is a collaborator
try:
collaborator = repository.get_collaborator_permission(username)
if collaborator in ['admin', 'write']:
return True
except Exception:
pass
# If the above fails, check if the user is an owner or member
org = repository.organization
if org:
user = org.get_members(username)
return user is not None
return False
async def is_job_requested(self, message: Message) -> bool:
self._confirm_incoming_source_type(message)
installation_id = message.message['installation']
payload = message.message.get('payload', {})
repo_obj = payload.get('repository')
if not repo_obj:
return False
username = payload.get('sender', {}).get('login')
repo_name = self._get_full_repo_name(repo_obj)
# Suggestions contain `@openhands` macro; avoid kicking off jobs for system recommendations
if GithubFactory.is_pr_comment(
message
) and GithubFailingAction.unqiue_suggestions_header in payload.get(
'comment', {}
).get('body', ''):
return False
if GithubFactory.is_eligible_for_conversation_starter(
message
) and self._user_has_write_access_to_repo(installation_id, repo_name, username):
await GithubFactory.trigger_conversation_starter(message)
if not (
GithubFactory.is_labeled_issue(message)
or GithubFactory.is_issue_comment(message)
or GithubFactory.is_pr_comment(message)
or GithubFactory.is_inline_pr_comment(message)
):
return False
logger.info(f'[GitHub] Checking permissions for {username} in {repo_name}')
return self._user_has_write_access_to_repo(installation_id, repo_name, username)
async def receive_message(self, message: Message):
self._confirm_incoming_source_type(message)
try:
await call_sync_from_async(self.data_collector.process_payload, message)
except Exception:
logger.warning(
'[Github]: Error processing payload for gh interaction', exc_info=True
)
if await self.is_job_requested(message):
github_view = await GithubFactory.create_github_view_from_payload(
message, self.token_manager
)
logger.info(
f'[GitHub] Creating job for {github_view.user_info.username} in {github_view.full_repo_name}#{github_view.issue_number}'
)
# Get the installation token
installation_token = self._get_installation_access_token(
github_view.installation_id
)
# Store the installation token
self.token_manager.store_org_token(
github_view.installation_id, installation_token
)
# Add eyes reaction to acknowledge we've read the request
self._add_reaction(github_view, 'eyes', installation_token)
await self.start_job(github_view)
async def send_message(self, message: Message, github_view: ResolverViewInterface):
installation_token = self.token_manager.load_org_token(
github_view.installation_id
)
if not installation_token:
logger.warning('Missing installation token')
return
outgoing_message = message.message
if isinstance(github_view, GithubInlinePRComment):
with Github(installation_token) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
pr = repo.get_pull(github_view.issue_number)
pr.create_review_comment_reply(
comment_id=github_view.comment_id, body=outgoing_message
)
elif (
isinstance(github_view, GithubPRComment)
or isinstance(github_view, GithubIssueComment)
or isinstance(github_view, GithubIssue)
):
with Github(installation_token) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
issue = repo.get_issue(number=github_view.issue_number)
issue.create_comment(outgoing_message)
else:
logger.warning('Unsupported location')
return
async def start_job(self, github_view: ResolverViewInterface):
"""Kick off a job with openhands agent.
1. Get user credential
2. Initialize new conversation with repo
3. Save interaction data
"""
# Importing here prevents circular import
from server.conversation_callback_processor.github_callback_processor import (
GithubCallbackProcessor,
)
try:
msg_info = None
try:
user_info = github_view.user_info
logger.info(
f'[GitHub] Starting job for user {user_info.username} (id={user_info.user_id})'
)
# Create conversation
user_token = await self.token_manager.get_idp_token_from_idp_user_id(
str(user_info.user_id), ProviderType.GITHUB
)
if not user_token:
logger.warning(
f'[GitHub] No token found for user {user_info.username} (id={user_info.user_id})'
)
raise MissingSettingsError('Missing settings')
logger.info(
f'[GitHub] Creating new conversation for user {user_info.username}'
)
secret_store = UserSecrets(
provider_tokens=MappingProxyType(
{
ProviderType.GITHUB: ProviderToken(
token=SecretStr(user_token),
user_id=str(user_info.user_id),
)
}
)
)
# We first initialize a conversation and generate the solvability report BEFORE starting the conversation runtime
# This helps us accumulate llm spend without requiring a running runtime. This setups us up for
# 1. If there is a problem starting the runtime we still have accumulated total conversation cost
# 2. In the future, based on the report confidence we can conditionally start the conversation
# 3. Once the conversation is started, its base cost will include the report's spend as well which allows us to control max budget per resolver task
convo_metadata = await github_view.initialize_new_conversation()
solvability_summary = None
try:
if user_token:
solvability_summary = await summarize_issue_solvability(
github_view, user_token
)
else:
logger.warning(
'[Github]: No user token available for solvability analysis'
)
except Exception as e:
logger.warning(
f'[Github]: Error summarizing issue solvability: {str(e)}'
)
await github_view.create_new_conversation(
self.jinja_env, secret_store.provider_tokens, convo_metadata
)
conversation_id = github_view.conversation_id
logger.info(
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
send_summary_instruction=True,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Github] Registered callback processor for conversation {conversation_id}'
)
# Send message with conversation link
conversation_link = CONVERSATION_URL.format(conversation_id)
base_msg = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
# Combine messages: include solvability report with "I'm on it!" if successful
if solvability_summary:
msg_info = f'{base_msg}\n\n{solvability_summary}'
else:
msg_info = base_msg
except MissingSettingsError as e:
logger.warning(
f'[GitHub] Missing settings error for user {user_info.username}: {str(e)}'
)
msg_info = f'@{user_info.username} please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
except LLMAuthenticationError as e:
logger.warning(
f'[GitHub] LLM authentication error for user {user_info.username}: {str(e)}'
)
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
msg = self.create_outgoing_message(msg_info)
await self.send_message(msg, github_view)
except Exception:
logger.exception('[Github]: Error starting job')
msg = self.create_outgoing_message(
msg='Uh oh! There was an unexpected error starting the job :('
)
await self.send_message(msg, github_view)
try:
await self.data_collector.save_data(github_view)
except Exception:
logger.warning('[Github]: Error saving interaction data', exc_info=True)

View File

@@ -0,0 +1,143 @@
import asyncio
from integrations.utils import store_repositories_in_db
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GitHubService
from openhands.integrations.service_types import ProviderType, Repository
from openhands.server.types import AppMode
class SaaSGitHubService(GitHubService):
def __init__(
self,
user_id: str | None = None,
external_auth_token: SecretStr | None = None,
external_auth_id: str | None = None,
token: SecretStr | None = None,
external_token_manager: bool = False,
base_domain: str | None = None,
):
logger.debug(
f'SaaSGitHubService created with user_id {user_id}, external_auth_id {external_auth_id}, external_auth_token {"set" if external_auth_token else "None"}, github_token {"set" if token else "None"}, external_token_manager {external_token_manager}'
)
super().__init__(
user_id=user_id,
external_auth_token=external_auth_token,
external_auth_id=external_auth_id,
token=token,
external_token_manager=external_token_manager,
base_domain=base_domain,
)
self.external_auth_token = external_auth_token
self.external_auth_id = external_auth_id
self.token_manager = TokenManager(external=external_token_manager)
async def get_latest_token(self) -> SecretStr | None:
github_token = None
if self.external_auth_token:
github_token = SecretStr(
await self.token_manager.get_idp_token(
self.external_auth_token.get_secret_value(), ProviderType.GITHUB
)
)
logger.debug(
f'Got GitHub token {github_token} from access token: {self.external_auth_token}'
)
elif self.external_auth_id:
offline_token = await self.token_manager.load_offline_token(
self.external_auth_id
)
github_token = SecretStr(
await self.token_manager.get_idp_token_from_offline_token(
offline_token, ProviderType.GITHUB
)
)
logger.debug(
f'Got GitHub token {github_token} from external auth user ID: {self.external_auth_id}'
)
elif self.user_id:
github_token = SecretStr(
await self.token_manager.get_idp_token_from_idp_user_id(
self.user_id, ProviderType.GITHUB
)
)
logger.debug(
f'Got GitHub token {github_token} from user ID: {self.user_id}'
)
else:
logger.warning('external_auth_token and user_id not set!')
return github_token
async def get_pr_patches(
self, owner: str, repo: str, pr_number: int, per_page: int = 30, page: int = 1
):
"""Get patches for files changed in a PR with pagination support.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
per_page: Number of files per page (default: 30, max: 100)
page: Page number to fetch (default: 1)
"""
url = f'https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/files'
params = {'per_page': min(per_page, 100), 'page': page} # GitHub max is 100
response, headers = await self._make_request(url, params)
# Parse pagination info from headers
has_next_page = 'next' in headers.get('link', '')
total_count = int(headers.get('total', 0))
return {
'files': response,
'pagination': {
'has_next_page': has_next_page,
'total_count': total_count,
'current_page': page,
'per_page': per_page,
},
}
async def get_repository_node_id(self, repo_id: str) -> str:
"""
Get the new GitHub GraphQL node ID for a repository using REST API.
Args:
repo_id: Numeric repository ID as string (e.g., "123456789")
Returns:
New format node ID for GraphQL queries (e.g., "R_kgDOLfkiww")
Raises:
Exception: If the API request fails or node_id is not found
"""
url = f'https://api.github.com/repositories/{repo_id}'
response, _ = await self._make_request(url)
node_id = response.get('node_id')
if not node_id:
raise Exception(f'No node_id found for repository {repo_id}')
return node_id
async def get_paginated_repos(self, page, per_page, sort, installation_id):
repositories = await super().get_paginated_repos(
page, per_page, sort, installation_id
)
asyncio.create_task(
store_repositories_in_db(repositories, self.external_auth_id)
)
return repositories
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
repositories = await super().get_all_repositories(sort, app_mode)
# Schedule the background task without awaiting it
asyncio.create_task(
store_repositories_in_db(repositories, self.external_auth_id)
)
# Return repositories immediately
return repositories

View File

@@ -0,0 +1,183 @@
import asyncio
import time
from github import Github
from integrations.github.github_view import (
GithubInlinePRComment,
GithubIssueComment,
GithubPRComment,
GithubViewType,
)
from integrations.solvability.data import load_classifier
from integrations.solvability.models.report import SolvabilityReport
from integrations.solvability.models.summary import SolvabilitySummary
from integrations.utils import ENABLE_SOLVABILITY_ANALYSIS
from pydantic import ValidationError
from server.auth.token_manager import get_config
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from openhands.core.config import LLMConfig
from openhands.core.logger import openhands_logger as logger
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.utils import create_registry_and_conversation_stats
def fetch_github_issue_context(
github_view: GithubViewType,
user_token: str,
) -> str:
"""Fetch full GitHub issue/PR context including title, body, and comments.
Args:
full_repo_name: Full repository name in the format 'owner/repo'
issue_number: The issue or PR number
user_token: GitHub user access token
max_comments: Maximum number of comments to fetch (default: 10)
max_comment_length: Maximum length of each comment to include in the context (default: 500)
Returns:
A comprehensive string containing the issue/PR context
"""
# Build context string
context_parts = []
# Add title and body
context_parts.append(f'Title: {github_view.title}')
context_parts.append(f'Description:\n{github_view.description}')
with Github(user_token) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
issue = repo.get_issue(github_view.issue_number)
if issue.labels:
labels = [label.name for label in issue.labels]
context_parts.append(f'Labels: {", ".join(labels)}')
for comment in github_view.previous_comments:
context_parts.append(f'- {comment.author}: {comment.body}')
return '\n\n'.join(context_parts)
async def summarize_issue_solvability(
github_view: GithubViewType,
user_token: str,
timeout: float = 60.0 * 5,
) -> str:
"""Generate a solvability summary for an issue using the resolver view interface.
Args:
resolver_view: A resolver view interface instance (e.g., GithubIssue, GithubPRComment)
user_token: GitHub user access token for API access
timeout: Maximum time in seconds to wait for the result (default: 60.0)
Returns:
The solvability summary as a string
Raises:
ValueError: If LLM settings cannot be found for the user
asyncio.TimeoutError: If the operation exceeds the specified timeout
"""
if not ENABLE_SOLVABILITY_ANALYSIS:
raise ValueError('Solvability report feature is disabled')
if github_view.user_info.keycloak_user_id is None:
raise ValueError(
f'[Solvability] No user ID found for user {github_view.user_info.username}'
)
# Grab the user's information so we can load their LLM configuration
store = SaasSettingsStore(
user_id=github_view.user_info.keycloak_user_id,
session_maker=session_maker,
config=get_config(),
)
user_settings = await store.load()
if user_settings is None:
raise ValueError(
f'[Solvability] No user settings found for user ID {github_view.user_info.user_id}'
)
# Check if solvability analysis is enabled for this user, exit early if
# needed
if not getattr(user_settings, 'enable_solvability_analysis', False):
raise ValueError(
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
)
try:
llm_config = LLMConfig(
model=user_settings.llm_model,
api_key=user_settings.llm_api_key.get_secret_value(),
base_url=user_settings.llm_base_url,
)
except ValidationError as e:
raise ValueError(
f'[Solvability] Invalid LLM configuration for user {github_view.user_info.user_id}: {str(e)}'
)
# Fetch the full GitHub issue/PR context using the GitHub API
start_time = time.time()
issue_context = fetch_github_issue_context(github_view, user_token)
logger.info(
f'[Solvability] Grabbed issue context for {github_view.conversation_id}',
extra={
'conversation_id': github_view.conversation_id,
'response_latency': time.time() - start_time,
'full_repo_name': github_view.full_repo_name,
'issue_number': github_view.issue_number,
},
)
# For comment-based triggers, also include the specific comment that triggered the action
if isinstance(
github_view, (GithubIssueComment, GithubPRComment, GithubInlinePRComment)
):
issue_context += f'\n\nTriggering Comment:\n{github_view.comment_body}'
solvability_classifier = load_classifier('default-classifier')
async with asyncio.timeout(timeout):
solvability_report: SolvabilityReport = await call_sync_from_async(
lambda: solvability_classifier.solvability_report(
issue_context, llm_config=llm_config
)
)
logger.info(
f'[Solvability] Generated report for {github_view.conversation_id}',
extra={
'conversation_id': github_view.conversation_id,
'report': solvability_report.model_dump(exclude=['issue']),
},
)
llm_registry, conversation_stats, _ = create_registry_and_conversation_stats(
get_config(),
github_view.conversation_id,
github_view.user_info.keycloak_user_id,
None,
)
solvability_summary = await call_sync_from_async(
lambda: SolvabilitySummary.from_report(
solvability_report,
llm=llm_registry.get_llm(
service_id='solvability_analysis', config=llm_config
),
)
)
conversation_stats.save_metrics()
logger.info(
f'[Solvability] Generated summary for {github_view.conversation_id}',
extra={
'conversation_id': github_view.conversation_id,
'summary': solvability_summary.model_dump(exclude=['content']),
},
)
return solvability_summary.format_as_markdown()

View File

@@ -0,0 +1,26 @@
from enum import Enum
from pydantic import BaseModel
class WorkflowRunStatus(Enum):
FAILURE = 'failure'
COMPLETED = 'completed'
PENDING = 'pending'
def __eq__(self, other):
if isinstance(other, str):
return self.value == other
return super().__eq__(other)
class WorkflowRun(BaseModel):
id: str
name: str
status: WorkflowRunStatus
model_config = {'use_enum_values': True}
class WorkflowRunGroup(BaseModel):
runs: dict[str, WorkflowRun]

View File

@@ -0,0 +1,756 @@
from uuid import uuid4
from github import Github, GithubIntegration
from github.Issue import Issue
from integrations.github.github_types import (
WorkflowRun,
WorkflowRunGroup,
WorkflowRunStatus,
)
from integrations.models import Message
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
HOST,
HOST_URL,
get_oh_labels,
has_exact_mention,
)
from jinja2 import Environment
from pydantic.dataclasses import dataclass
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager, get_config
from storage.database import session_maker
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.user_settings import UserSettings
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import call_sync_from_async
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
"""Get the user's proactive conversation setting.
Args:
user_id: The keycloak user ID
Returns:
True if proactive conversations are enabled for this user, False otherwise
Note:
This function checks both the global environment variable kill switch AND
the user's individual setting. Both must be true for the function to return true.
"""
# If no user ID is provided, we can't check user settings
if not user_id:
return False
def _get_setting():
with session_maker() as session:
settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
if not settings or settings.enable_proactive_conversation_starters is None:
return False
return settings.enable_proactive_conversation_starters
return await call_sync_from_async(_get_setting)
# =================================================
# SECTION: Github view types
# =================================================
@dataclass
class GithubIssue(ResolverViewInterface):
issue_number: int
installation_id: int
full_repo_name: str
is_public_repo: bool
user_info: UserData
raw_payload: Message
conversation_id: str
uuid: str | None
should_extract: bool
send_summary_instruction: bool
title: str
description: str
previous_comments: list[Comment]
async def _load_resolver_context(self):
github_service = GithubServiceImpl(
external_auth_id=self.user_info.keycloak_user_id
)
self.previous_comments = await github_service.get_issue_or_pr_comments(
self.full_repo_name, self.issue_number
)
(
self.title,
self.description,
) = await github_service.get_issue_or_pr_title_and_body(
self.full_repo_name, self.issue_number
)
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
user_instructions_template = jinja_env.get_template('issue_prompt.j2')
user_instructions = user_instructions_template.render(
issue_number=self.issue_number,
)
await self._load_resolver_context()
conversation_instructions_template = jinja_env.get_template(
'issue_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
issue_title=self.title,
issue_body=self.description,
previous_comments=self.previous_comments,
)
return user_instructions, conversation_instructions
async def _get_user_secrets(self):
secrets_store = SaasSecretsStore(
self.user_info.keycloak_user_id, session_maker, get_config()
)
user_secrets = await secrets_store.load()
return user_secrets.custom_secrets if user_secrets else None
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=None,
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
)
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
async def create_new_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
await start_conversation(
user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
custom_secrets=custom_secrets,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_metadata.conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=conversation_instructions,
)
@dataclass
class GithubIssueComment(GithubIssue):
comment_body: str
comment_id: int
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
user_instructions_template = jinja_env.get_template('issue_prompt.j2')
await self._load_resolver_context()
user_instructions = user_instructions_template.render(
issue_comment=self.comment_body
)
conversation_instructions_template = jinja_env.get_template(
'issue_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
issue_number=self.issue_number,
issue_title=self.title,
issue_body=self.description,
previous_comments=self.previous_comments,
)
return user_instructions, conversation_instructions
@dataclass
class GithubPRComment(GithubIssueComment):
branch_name: str
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
user_instructions_template = jinja_env.get_template('pr_update_prompt.j2')
await self._load_resolver_context()
user_instructions = user_instructions_template.render(
pr_comment=self.comment_body,
)
conversation_instructions_template = jinja_env.get_template(
'pr_update_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
pr_number=self.issue_number,
branch_name=self.branch_name,
pr_title=self.title,
pr_body=self.description,
comments=self.previous_comments,
)
return user_instructions, conversation_instructions
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self.branch_name,
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
)
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
@dataclass
class GithubInlinePRComment(GithubPRComment):
file_location: str
line_number: int
comment_node_id: str
async def _load_resolver_context(self):
github_service = GithubServiceImpl(
external_auth_id=self.user_info.keycloak_user_id
)
(
self.title,
self.description,
) = await github_service.get_issue_or_pr_title_and_body(
self.full_repo_name, self.issue_number
)
self.previous_comments = await github_service.get_review_thread_comments(
self.comment_node_id, self.full_repo_name, self.issue_number
)
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
user_instructions_template = jinja_env.get_template('pr_update_prompt.j2')
await self._load_resolver_context()
user_instructions = user_instructions_template.render(
pr_comment=self.comment_body,
)
conversation_instructions_template = jinja_env.get_template(
'pr_update_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
pr_number=self.issue_number,
pr_title=self.title,
pr_body=self.description,
branch_name=self.branch_name,
file_location=self.file_location,
line_number=self.line_number,
comments=self.previous_comments,
)
return user_instructions, conversation_instructions
@dataclass
class GithubFailingAction:
unqiue_suggestions_header: str = (
'Looks like there are a few issues preventing this PR from being merged!'
)
@staticmethod
def get_latest_sha(pr: Issue) -> str:
pr_obj = pr.as_pull_request()
return pr_obj.head.sha
@staticmethod
def create_retrieve_workflows_callback(pr: Issue, head_sha: str):
def get_all_workflows():
repo = pr.repository
workflows = repo.get_workflow_runs(head_sha=head_sha)
runs = {}
for workflow in workflows:
conclusion = workflow.conclusion
workflow_conclusion = WorkflowRunStatus.COMPLETED
if conclusion is None:
workflow_conclusion = WorkflowRunStatus.PENDING # type: ignore[unreachable]
elif conclusion == WorkflowRunStatus.FAILURE.value:
workflow_conclusion = WorkflowRunStatus.FAILURE
runs[str(workflow.id)] = WorkflowRun(
id=str(workflow.id), name=workflow.name, status=workflow_conclusion
)
return WorkflowRunGroup(runs=runs)
return get_all_workflows
@staticmethod
def delete_old_comment_if_exists(pr: Issue):
paginated_comments = pr.get_comments()
for page in range(paginated_comments.totalCount):
comments = paginated_comments.get_page(page)
for comment in comments:
if GithubFailingAction.unqiue_suggestions_header in comment.body:
comment.delete()
@staticmethod
def get_suggestions(
failed_jobs: dict, pr_number: int, branch_name: str | None = None
) -> str:
issues = []
# Collect failing actions with their specific names
if failed_jobs['actions']:
failing_actions = failed_jobs['actions']
issues.append(('GitHub Actions are failing:', False))
for action in failing_actions:
issues.append((action, True))
if any(failed_jobs['merge conflict']):
issues.append(('There are merge conflicts', False))
# Format each line with proper indentation and dashes
formatted_issues = []
for issue, is_nested in issues:
if is_nested:
formatted_issues.append(f' - {issue}')
else:
formatted_issues.append(f'- {issue}')
issues_text = '\n'.join(formatted_issues)
# Build list of possible suggestions based on actual issues
suggestions = []
branch_info = f' at branch `{branch_name}`' if branch_name else ''
if any(failed_jobs['merge conflict']):
suggestions.append(
f'@OpenHands please fix the merge conflicts on PR #{pr_number}{branch_info}'
)
if any(failed_jobs['actions']):
suggestions.append(
f'@OpenHands please fix the failing actions on PR #{pr_number}{branch_info}'
)
# Take at most 2 suggestions
suggestions = suggestions[:2]
help_text = """If you'd like me to help, just leave a comment, like
```
{}
```
Feel free to include any additional details that might help me get this PR into a better state.
<sub><sup>You can manage your notification [settings]({})</sup></sub>""".format(
'\n```\n\nor\n\n```\n'.join(suggestions), f'{HOST_URL}/settings/app'
)
return f'{GithubFailingAction.unqiue_suggestions_header}\n\n{issues_text}\n\n{help_text}'
@staticmethod
def leave_requesting_comment(pr: Issue, failed_runs: WorkflowRunGroup):
failed_jobs: dict = {'actions': [], 'merge conflict': []}
pr_obj = pr.as_pull_request()
if not pr_obj.mergeable:
failed_jobs['merge conflict'].append('Merge conflict detected')
for _, workflow_run in failed_runs.runs.items():
if workflow_run.status == WorkflowRunStatus.FAILURE:
failed_jobs['actions'].append(workflow_run.name)
logger.info(f'[GitHub] Found failing jobs for PR #{pr.number}: {failed_jobs}')
# Get the branch name
branch_name = pr_obj.head.ref
# Get suggestions with branch name included
suggestions = GithubFailingAction.get_suggestions(
failed_jobs, pr.number, branch_name
)
GithubFailingAction.delete_old_comment_if_exists(pr)
pr.create_comment(suggestions)
GithubViewType = (
GithubInlinePRComment | GithubPRComment | GithubIssueComment | GithubIssue
)
# =================================================
# SECTION: Factory to create appriorate Github view
# =================================================
class GithubFactory:
@staticmethod
def is_labeled_issue(message: Message):
payload = message.message.get('payload', {})
action = payload.get('action', '')
if action == 'labeled' and 'label' in payload and 'issue' in payload:
label_name = payload['label'].get('name', '')
if label_name == OH_LABEL:
return True
return False
@staticmethod
def is_issue_comment(message: Message):
payload = message.message.get('payload', {})
action = payload.get('action', '')
if (
action == 'created'
and 'comment' in payload
and 'issue' in payload
and 'pull_request' not in payload['issue']
):
comment_body = payload['comment']['body']
if has_exact_mention(comment_body, INLINE_OH_LABEL):
return True
return False
@staticmethod
def is_pr_comment(message: Message):
payload = message.message.get('payload', {})
action = payload.get('action', '')
if (
action == 'created'
and 'comment' in payload
and 'issue' in payload
and 'pull_request' in payload['issue']
):
comment_body = payload['comment'].get('body', '')
if has_exact_mention(comment_body, INLINE_OH_LABEL):
return True
return False
@staticmethod
def is_inline_pr_comment(message: Message):
payload = message.message.get('payload', {})
action = payload.get('action', '')
if action == 'created' and 'comment' in payload and 'pull_request' in payload:
comment_body = payload['comment'].get('body', '')
if has_exact_mention(comment_body, INLINE_OH_LABEL):
return True
return False
@staticmethod
def is_eligible_for_conversation_starter(message: Message):
if not ENABLE_PROACTIVE_CONVERSATION_STARTERS:
return False
payload = message.message.get('payload', {})
action = payload.get('action', '')
if not (action == 'completed' and 'workflow_run' in payload):
return False
return True
@staticmethod
async def trigger_conversation_starter(message: Message):
"""Trigger a conversation starter when a workflow fails.
This is the updated version that checks user settings.
"""
payload = message.message.get('payload', {})
workflow_payload = payload['workflow_run']
status = WorkflowRunStatus.COMPLETED
if workflow_payload['conclusion'] == 'failure':
status = WorkflowRunStatus.FAILURE
elif workflow_payload['conclusion'] is None:
status = WorkflowRunStatus.PENDING
workflow_run = WorkflowRun(
id=str(workflow_payload['id']), name=workflow_payload['name'], status=status
)
selected_repo = GithubFactory.get_full_repo_name(payload['repository'])
head_branch = payload['workflow_run']['head_branch']
# Get the user ID to check their settings
user_id = None
try:
sender_id = payload['sender']['id']
token_manager = TokenManager()
user_id = await token_manager.get_user_id_from_idp_user_id(
sender_id, ProviderType.GITHUB
)
except (KeyError, Exception) as e:
logger.warning(
f'Failed to get user ID for proactive conversation check: {str(e)}'
)
# Check if proactive conversations are enabled for this user
if not await get_user_proactive_conversation_setting(user_id):
return False
def _interact_with_github() -> Issue | None:
with GithubIntegration(
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
) as integration:
access_token = integration.get_access_token(
payload['installation']['id']
).token
with Github(access_token) as gh:
repo = gh.get_repo(selected_repo)
login = (
payload['organization']['login']
if 'organization' in payload
else payload['sender']['login']
)
# See if a pull request is open
open_pulls = repo.get_pulls(state='open', head=f'{login}:{head_branch}')
if open_pulls.totalCount > 0:
prs = open_pulls.get_page(0)
relevant_pr = prs[0]
issue = repo.get_issue(number=relevant_pr.number)
return issue
return None
issue: Issue | None = await call_sync_from_async(_interact_with_github)
if not issue:
return False
incoming_commit = payload['workflow_run']['head_sha']
latest_sha = GithubFailingAction.get_latest_sha(issue)
if latest_sha != incoming_commit:
# Return as this commit is not the latest
return False
convo_store = ProactiveConversationStore()
workflow_group = await convo_store.store_workflow_information(
provider=ProviderType.GITHUB,
repo_id=payload['repository']['id'],
incoming_commit=incoming_commit,
workflow=workflow_run,
pr_number=issue.number,
get_all_workflows=GithubFailingAction.create_retrieve_workflows_callback(
issue, incoming_commit
),
)
if not workflow_group:
return False
logger.info(
f'[GitHub] Workflow completed for {selected_repo}#{issue.number} on branch {head_branch}'
)
GithubFailingAction.leave_requesting_comment(issue, workflow_group)
return False
@staticmethod
def get_full_repo_name(repo_obj: dict) -> str:
owner = repo_obj['owner']['login']
repo_name = repo_obj['name']
return f'{owner}/{repo_name}'
@staticmethod
async def create_github_view_from_payload(
message: Message, token_manager: TokenManager
) -> ResolverViewInterface:
"""Create the appropriate class (GithubIssue or GithubPRComment) based on the payload.
Also return metadata about the event (e.g., action type).
"""
payload = message.message.get('payload', {})
repo_obj = payload['repository']
user_id = payload['sender']['id']
username = payload['sender']['login']
keyloak_user_id = await token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
if keyloak_user_id is None:
logger.warning(f'Got invalid keyloak user id for GitHub User {user_id} ')
selected_repo = GithubFactory.get_full_repo_name(repo_obj)
is_public_repo = not repo_obj.get('private', True)
user_info = UserData(
user_id=user_id, username=username, keycloak_user_id=keyloak_user_id
)
installation_id = message.message['installation']
if GithubFactory.is_labeled_issue(message):
issue_number = payload['issue']['number']
logger.info(
f'[GitHub] Creating view for labeled issue from {username} in {selected_repo}#{issue_number}'
)
return GithubIssue(
issue_number=issue_number,
installation_id=installation_id,
full_repo_name=selected_repo,
is_public_repo=is_public_repo,
raw_payload=message,
user_info=user_info,
conversation_id='',
uuid=str(uuid4()),
should_extract=True,
send_summary_instruction=True,
title='',
description='',
previous_comments=[],
)
elif GithubFactory.is_issue_comment(message):
issue_number = payload['issue']['number']
comment_body = payload['comment']['body']
comment_id = payload['comment']['id']
logger.info(
f'[GitHub] Creating view for issue comment from {username} in {selected_repo}#{issue_number}'
)
return GithubIssueComment(
issue_number=issue_number,
comment_body=comment_body,
comment_id=comment_id,
installation_id=installation_id,
full_repo_name=selected_repo,
is_public_repo=is_public_repo,
raw_payload=message,
user_info=user_info,
conversation_id='',
uuid=None,
should_extract=True,
send_summary_instruction=True,
title='',
description='',
previous_comments=[],
)
elif GithubFactory.is_pr_comment(message):
issue_number = payload['issue']['number']
logger.info(
f'[GitHub] Creating view for PR comment from {username} in {selected_repo}#{issue_number}'
)
access_token = ''
with GithubIntegration(
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
) as integration:
access_token = integration.get_access_token(installation_id).token
head_ref = None
with Github(access_token) as gh:
repo = gh.get_repo(selected_repo)
pull_request = repo.get_pull(issue_number)
head_ref = pull_request.head.ref
logger.info(
f'[GitHub] Found PR branch {head_ref} for {selected_repo}#{issue_number}'
)
comment_id = payload['comment']['id']
return GithubPRComment(
issue_number=issue_number,
branch_name=head_ref,
comment_body=payload['comment']['body'],
comment_id=comment_id,
installation_id=installation_id,
full_repo_name=selected_repo,
is_public_repo=is_public_repo,
raw_payload=message,
user_info=user_info,
conversation_id='',
uuid=None,
should_extract=True,
send_summary_instruction=True,
title='',
description='',
previous_comments=[],
)
elif GithubFactory.is_inline_pr_comment(message):
pr_number = payload['pull_request']['number']
branch_name = payload['pull_request']['head']['ref']
comment_id = payload['comment']['id']
comment_node_id = payload['comment']['node_id']
file_path = payload['comment']['path']
line_number = payload['comment']['line']
logger.info(
f'[GitHub] Creating view for inline PR comment from {username} in {selected_repo}#{pr_number} at {file_path}'
)
return GithubInlinePRComment(
issue_number=pr_number,
branch_name=branch_name,
comment_body=payload['comment']['body'],
comment_node_id=comment_node_id,
comment_id=comment_id,
file_location=file_path,
line_number=line_number,
installation_id=installation_id,
full_repo_name=selected_repo,
is_public_repo=is_public_repo,
raw_payload=message,
user_info=user_info,
conversation_id='',
uuid=None,
should_extract=True,
send_summary_instruction=True,
title='',
description='',
previous_comments=[],
)
else:
raise ValueError(
"Invalid payload: must contain either 'issue' or 'pull_request'"
)

View File

@@ -0,0 +1,102 @@
PR_QUERY_BY_NODE_ID = """
query($nodeId: ID!, $pr_number: Int!, $commits_after: String, $comments_after: String, $reviews_after: String) {
node(id: $nodeId) {
... on Repository {
name
owner {
login
}
languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
nodes {
name
}
}
pullRequest(number: $pr_number) {
number
title
body
author {
login
}
merged
mergedAt
mergedBy {
login
}
state
mergeCommit {
oid
}
comments(first: 50, after: $comments_after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
author {
login
}
body
createdAt
}
}
commits(first: 50, after: $commits_after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
commit {
oid
message
committedDate
author {
name
email
user {
login
}
}
additions
deletions
changedFiles
}
}
}
reviews(first: 50, after: $reviews_after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
author {
login
}
body
state
createdAt
comments(first: 50) {
pageInfo {
hasNextPage
endCursor
}
nodes {
author {
login
}
body
createdAt
}
}
}
}
}
}
}
rateLimit {
remaining
limit
resetAt
}
}
"""

View File

@@ -0,0 +1,261 @@
from types import MappingProxyType
from integrations.gitlab.gitlab_view import (
GitlabFactory,
GitlabInlineMRComment,
GitlabIssue,
GitlabIssueComment,
GitlabMRComment,
GitlabViewType,
)
from integrations.manager import Manager
from integrations.models import Message, SourceType
from integrations.types import ResolverViewInterface
from integrations.utils import (
CONVERSATION_URL,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
)
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.storage.data_models.user_secrets import UserSecrets
class GitlabManager(Manager):
def __init__(self, token_manager: TokenManager, data_collector: None = None):
self.token_manager = token_manager
self.jinja_env = Environment(
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'gitlab')
)
def _confirm_incoming_source_type(self, message: Message):
if message.source != SourceType.GITLAB:
raise ValueError(f'Unexpected message source {message.source}')
async def _user_has_write_access_to_repo(
self, project_id: str, user_id: str
) -> bool:
"""
Check if the user has write access to the repository (can pull/push changes and open merge requests).
Args:
project_id: The ID of the GitLab project
username: The username of the user
user_id: The GitLab user ID
Returns:
bool: True if the user has write access to the repository, False otherwise
"""
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITLAB
)
if keycloak_user_id is None:
logger.warning(f'Got invalid keyloak user id for GitLab User {user_id}')
return False
# Importing here prevents circular import
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
external_auth_id=keycloak_user_id
)
return await gitlab_service.user_has_write_access(project_id)
async def receive_message(self, message: Message):
self._confirm_incoming_source_type(message)
if await self.is_job_requested(message):
gitlab_view = await GitlabFactory.create_gitlab_view_from_payload(
message, self.token_manager
)
logger.info(
f'[GitLab] Creating job for {gitlab_view.user_info.username} in {gitlab_view.full_repo_name}#{gitlab_view.issue_number}'
)
await self.start_job(gitlab_view)
async def is_job_requested(self, message) -> bool:
self._confirm_incoming_source_type(message)
if not (
GitlabFactory.is_labeled_issue(message)
or GitlabFactory.is_issue_comment(message)
or GitlabFactory.is_mr_comment(message)
or GitlabFactory.is_mr_comment(message, inline=True)
):
return False
payload = message.message['payload']
repo_obj = payload['project']
project_id = repo_obj['id']
selected_project = repo_obj['path_with_namespace']
user = payload['user']
user_id = user['id']
username = user['username']
logger.info(
f'[GitLab] Checking permissions for {username} in {selected_project}'
)
has_write_access = await self._user_has_write_access_to_repo(
project_id=str(project_id), user_id=user_id
)
logger.info(
f'[GitLab]: {username} access in {selected_project}: {has_write_access}'
)
# Check if the user has write access to the repository
return has_write_access
async def send_message(self, message: Message, gitlab_view: ResolverViewInterface):
"""
Send a message to GitLab based on the view type.
Args:
message: The message to send
gitlab_view: The GitLab view object containing issue/PR/comment info
"""
keycloak_user_id = gitlab_view.user_info.keycloak_user_id
# Importing here prevents circular import
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
external_auth_id=keycloak_user_id
)
outgoing_message = message.message
if isinstance(gitlab_view, GitlabInlineMRComment) or isinstance(
gitlab_view, GitlabMRComment
):
await gitlab_service.reply_to_mr(
gitlab_view.project_id,
gitlab_view.issue_number,
gitlab_view.discussion_id,
message.message,
)
elif isinstance(gitlab_view, GitlabIssueComment):
await gitlab_service.reply_to_issue(
gitlab_view.project_id,
gitlab_view.issue_number,
gitlab_view.discussion_id,
outgoing_message,
)
elif isinstance(gitlab_view, GitlabIssue):
await gitlab_service.reply_to_issue(
gitlab_view.project_id,
gitlab_view.issue_number,
None, # no discussion id, issue is tagged
outgoing_message,
)
else:
logger.warning(
f'[GitLab] Unsupported view type: {type(gitlab_view).__name__}'
)
async def start_job(self, gitlab_view: GitlabViewType):
"""
Start a job for the GitLab view.
Args:
gitlab_view: The GitLab view object containing issue/PR/comment info
"""
# Importing here prevents circular import
from server.conversation_callback_processor.gitlab_callback_processor import (
GitlabCallbackProcessor,
)
try:
try:
user_info = gitlab_view.user_info
logger.info(
f'[GitLab] Starting job for {user_info.username} in {gitlab_view.full_repo_name}#{gitlab_view.issue_number}'
)
user_token = await self.token_manager.get_idp_token_from_idp_user_id(
str(user_info.user_id), ProviderType.GITLAB
)
if not user_token:
logger.warning(
f'[GitLab] No token found for user {user_info.username} (id={user_info.user_id})'
)
raise MissingSettingsError('Missing settings')
logger.info(
f'[GitLab] Creating new conversation for user {user_info.username}'
)
secret_store = UserSecrets(
provider_tokens=MappingProxyType(
{
ProviderType.GITLAB: ProviderToken(
token=SecretStr(user_token),
user_id=str(user_info.user_id),
)
}
)
)
await gitlab_view.create_new_conversation(
self.jinja_env, secret_store.provider_tokens
)
conversation_id = gitlab_view.conversation_id
logger.info(
f'[GitLab] Created conversation {conversation_id} for user {user_info.username}'
)
# Create a GitlabCallbackProcessor for this conversation
processor = GitlabCallbackProcessor(
gitlab_view=gitlab_view,
send_summary_instruction=True,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[GitLab] Created callback processor for conversation {conversation_id}'
)
conversation_link = CONVERSATION_URL.format(conversation_id)
msg_info = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
except MissingSettingsError as e:
logger.warning(
f'[GitLab] Missing settings error for user {user_info.username}: {str(e)}'
)
msg_info = f'@{user_info.username} please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
except LLMAuthenticationError as e:
logger.warning(
f'[GitLab] LLM authentication error for user {user_info.username}: {str(e)}'
)
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
# Send the acknowledgment message
msg = self.create_outgoing_message(msg_info)
await self.send_message(msg, gitlab_view)
except Exception as e:
logger.exception(f'[GitLab] Error starting job: {str(e)}')
msg = self.create_outgoing_message(
msg='Uh oh! There was an unexpected error starting the job :('
)
await self.send_message(msg, gitlab_view)

View File

@@ -0,0 +1,529 @@
import asyncio
from integrations.types import GitLabResourceType
from integrations.utils import store_repositories_in_db
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
from storage.gitlab_webhook_store import GitlabWebhookStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.gitlab_service import GitLabService
from openhands.integrations.service_types import (
ProviderType,
RateLimitError,
Repository,
RequestMethod,
)
from openhands.server.types import AppMode
class SaaSGitLabService(GitLabService):
def __init__(
self,
user_id: str | None = None,
external_auth_token: SecretStr | None = None,
external_auth_id: str | None = None,
token: SecretStr | None = None,
external_token_manager: bool = False,
base_domain: str | None = None,
):
logger.info(
f'SaaSGitLabService created with user_id {user_id}, external_auth_id {external_auth_id}, external_auth_token {"set" if external_auth_token else "None"}, gitlab_token {"set" if token else "None"}, external_token_manager {external_token_manager}'
)
super().__init__(
user_id=user_id,
external_auth_token=external_auth_token,
external_auth_id=external_auth_id,
token=token,
external_token_manager=external_token_manager,
base_domain=base_domain,
)
self.external_auth_token = external_auth_token
self.external_auth_id = external_auth_id
self.token_manager = TokenManager(external=external_token_manager)
async def get_latest_token(self) -> SecretStr | None:
gitlab_token = None
if self.external_auth_token:
gitlab_token = SecretStr(
await self.token_manager.get_idp_token(
self.external_auth_token.get_secret_value(), idp=ProviderType.GITLAB
)
)
logger.debug(
f'Got GitLab token {gitlab_token} from access token: {self.external_auth_token}'
)
elif self.external_auth_id:
offline_token = await self.token_manager.load_offline_token(
self.external_auth_id
)
gitlab_token = SecretStr(
await self.token_manager.get_idp_token_from_offline_token(
offline_token, ProviderType.GITLAB
)
)
logger.info(
f'Got GitLab token {gitlab_token.get_secret_value()} from external auth user ID: {self.external_auth_id}'
)
elif self.user_id:
gitlab_token = SecretStr(
await self.token_manager.get_idp_token_from_idp_user_id(
self.user_id, ProviderType.GITLAB
)
)
logger.debug(
f'Got Gitlab token {gitlab_token} from user ID: {self.user_id}'
)
else:
logger.warning('external_auth_token and user_id not set!')
return gitlab_token
async def get_owned_groups(self) -> list[dict]:
"""
Get all groups for which the current user is the owner.
Returns:
list[dict]: A list of groups owned by the current user.
"""
url = f'{self.BASE_URL}/groups'
params = {'owned': 'true', 'per_page': 100, 'top_level_only': 'true'}
try:
response, headers = await self._make_request(url, params)
return response
except Exception:
logger.warning('Error fetching owned groups', exc_info=True)
return []
async def add_owned_projects_and_groups_to_db(self, owned_personal_projects):
"""
Add owned projects and groups to the database for webhook tracking.
Args:
owned_personal_projects: List of personal projects owned by the user
"""
owned_groups = await self.get_owned_groups()
webhooks = []
def build_group_webhook_entries(groups):
return [
GitlabWebhook(
group_id=str(group['id']),
project_id=None,
user_id=self.external_auth_id,
webhook_exists=False,
)
for group in groups
]
def build_project_webhook_entries(projects):
return [
GitlabWebhook(
group_id=None,
project_id=str(project['id']),
user_id=self.external_auth_id,
webhook_exists=False,
)
for project in projects
]
# Collect all webhook entries
webhooks.extend(build_group_webhook_entries(owned_groups))
webhooks.extend(build_project_webhook_entries(owned_personal_projects))
# Store webhooks in the database
if webhooks:
try:
webhook_store = GitlabWebhookStore()
await webhook_store.store_webhooks(webhooks)
logger.info(
f'Added GitLab webhooks to db for user {self.external_auth_id}'
)
except Exception:
logger.warning('Failed to add Gitlab webhooks to db', exc_info=True)
async def store_repository_data(
self, users_personal_projects: list[dict], repositories: list[Repository]
) -> None:
"""
Store repository data in the database.
This function combines the functionality of add_owned_projects_and_groups_to_db and store_repositories_in_db.
Args:
users_personal_projects: List of personal projects owned by the user
repositories: List of Repository objects to store
"""
try:
# First, add owned projects and groups to the database
await self.add_owned_projects_and_groups_to_db(users_personal_projects)
# Then, store repositories in the database
await store_repositories_in_db(repositories, self.external_auth_id)
logger.info(
f'Successfully stored repository data for user {self.external_auth_id}'
)
except Exception:
logger.warning('Error storing repository data', exc_info=True)
async def get_all_repositories(
self, sort: str, app_mode: AppMode, store_in_background: bool = True
) -> list[Repository]:
"""
Get repositories for the authenticated user, including information about the kind of project.
Also collects repositories where the kind is "user" and the user is the owner.
Args:
sort: The field to sort repositories by
app_mode: The application mode (OSS or SAAS)
Returns:
List[Repository]: A list of repositories for the authenticated user
"""
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []
users_personal_projects: list[dict] = []
page = 1
url = f'{self.BASE_URL}/projects'
# Map GitHub's sort values to GitLab's order_by values
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
user_id = None
try:
user_info = await self.get_user()
user_id = user_info.id
except Exception as e:
logger.warning(f'Could not fetch user id: {e}')
while len(all_repos) < MAX_REPOS:
params = {
'page': str(page),
'per_page': str(PER_PAGE),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'membership': 1, # Use 1 instead of True
}
try:
response, headers = await self._make_request(url, params)
if not response: # No more repositories
break
# Process each repository to identify user-owned ones
for repo in response:
namespace = repo.get('namespace', {})
kind = namespace.get('kind')
owner_id = repo.get('owner', {}).get('id')
# Collect user owned personal projects
if kind == 'user' and str(owner_id) == str(user_id):
users_personal_projects.append(repo)
# Add to all repos regardless
all_repos.append(repo)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
except Exception:
logger.warning(
f'Error fetching repositories on page {page}', exc_info=True
)
break
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
repositories = [
Repository(
id=str(repo.get('id')),
full_name=str(repo.get('path_with_namespace')),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
)
for repo in all_repos
]
# Store webhook and repository info
if store_in_background:
asyncio.create_task(
self.store_repository_data(users_personal_projects, repositories)
)
else:
await self.store_repository_data(users_personal_projects, repositories)
return repositories
async def check_resource_exists(
self, resource_type: GitLabResourceType, resource_id: str
) -> tuple[bool, WebhookStatus | None]:
"""
Check if resource exists and the user has access to it.
Args:
resource_type: The type of resource
resource_id: The ID of resource to check
Returns:
tuple[bool, str]: A tuple containing:
- bool: True if the resource exists and the user has access to it, False otherwise
- str: A reason message explaining the result
"""
if resource_type == GitLabResourceType.GROUP:
url = f'{self.BASE_URL}/groups/{resource_id}'
else:
url = f'{self.BASE_URL}/projects/{resource_id}'
try:
response, _ = await self._make_request(url)
# If we get a response, the resource exists and the user has access to it
return bool(response and 'id' in response), None
except RateLimitError:
return False, WebhookStatus.RATE_LIMITED
except Exception:
logger.warning('Resource existence check failed', exc_info=True)
return False, WebhookStatus.INVALID
async def check_webhook_exists_on_resource(
self, resource_type: GitLabResourceType, resource_id: str, webhook_url: str
) -> tuple[bool, WebhookStatus | None]:
"""
Check if a webhook already exists for resource with a specific URL.
Args:
resource_type: The type of resource
resource_id: The ID of the resource to check
webhook_url: The URL of the webhook to check for
Returns:
tuple[bool, str]: A tuple containing:
- bool: True if the webhook exists, False otherwise
- str: A reason message explaining the result
"""
# Construct the URL based on the resource type
if resource_type == GitLabResourceType.GROUP:
url = f'{self.BASE_URL}/groups/{resource_id}/hooks'
else:
url = f'{self.BASE_URL}/projects/{resource_id}/hooks'
try:
# Get all webhooks for the resource
response, _ = await self._make_request(url)
# Check if any webhook has the specified URL
exists = False
if response:
for webhook in response:
if webhook.get('url') == webhook_url:
exists = True
return exists, None
except RateLimitError:
return False, WebhookStatus.RATE_LIMITED
except Exception:
logger.warning('Webhook existence check failed', exc_info=True)
return False, WebhookStatus.INVALID
async def check_user_has_admin_access_to_resource(
self, resource_type: GitLabResourceType, resource_id: str
) -> tuple[bool, WebhookStatus | None]:
"""
Check if the user has admin access to resource (is either an owner or maintainer)
Args:
resource_type: The type of resource
resource_id: The ID of the resource to check
Returns:
tuple[bool, str]: A tuple containing:
- bool: True if the user has admin access to the resource (owner or maintainer), False otherwise
- str: A reason message explaining the result
"""
# For groups, we need to check if the user is an owner or maintainer
if resource_type == GitLabResourceType.GROUP:
url = f'{self.BASE_URL}/groups/{resource_id}/members/all'
try:
response, _ = await self._make_request(url)
# Check if the current user is in the members list with access level >= 40 (Maintainer or Owner)
exists = False
if response:
current_user = await self.get_user()
user_id = current_user.id
for member in response:
if (
str(member.get('id')) == str(user_id)
and member.get('access_level', 0) >= 40
):
exists = True
return exists, None
except RateLimitError:
return False, WebhookStatus.RATE_LIMITED
except Exception:
return False, WebhookStatus.INVALID
# For projects, we need to check if the user has maintainer or owner access
else:
url = f'{self.BASE_URL}/projects/{resource_id}/members/all'
try:
response, _ = await self._make_request(url)
exists = False
# Check if the current user is in the members list with access level >= 40 (Maintainer)
if response:
current_user = await self.get_user()
user_id = current_user.id
for member in response:
if (
str(member.get('id')) == str(user_id)
and member.get('access_level', 0) >= 40
):
exists = True
return exists, None
except RateLimitError:
return False, WebhookStatus.RATE_LIMITED
except Exception:
logger.warning('Admin access check failed', exc_info=True)
return False, WebhookStatus.INVALID
async def install_webhook(
self,
resource_type: GitLabResourceType,
resource_id: str,
webhook_name: str,
webhook_url: str,
webhook_secret: str,
webhook_uuid: str,
scopes: list[str],
) -> tuple[str | None, WebhookStatus | None]:
"""
Install webhook for user's group or project
Args:
resource_type: The type of resource
resource_id: The ID of the resource to check
webhook_secret: Webhook secret that is used to verify payload
webhook_name: Name of webhook
webhook_url: Webhook URL
scopes: activity webhook listens for
Returns:
tuple[bool, str]: A tuple containing:
- bool: True if installation was successful, False otherwise
- str: A reason message explaining the result
"""
description = 'Cloud OpenHands Resolver'
# Set up webhook parameters
webhook_data = {
'url': webhook_url,
'name': webhook_name,
'enable_ssl_verification': True,
'token': webhook_secret,
'description': description,
}
for scope in scopes:
webhook_data[scope] = True
# Add custom headers with user id
if self.external_auth_id:
webhook_data['custom_headers'] = [
{'key': 'X-OpenHands-User-ID', 'value': self.external_auth_id},
{'key': 'X-OpenHands-Webhook-ID', 'value': webhook_uuid},
]
if resource_type == GitLabResourceType.GROUP:
url = f'{self.BASE_URL}/groups/{resource_id}/hooks'
else:
url = f'{self.BASE_URL}/projects/{resource_id}/hooks'
try:
# Make the API request
response, _ = await self._make_request(
url=url, params=webhook_data, method=RequestMethod.POST
)
if response and 'id' in response:
return str(response['id']), None
# Check if the webhook was created successfully
return None, None
except RateLimitError:
return None, WebhookStatus.RATE_LIMITED
except Exception:
logger.warning('Webhook installation failed', exc_info=True)
return None, WebhookStatus.INVALID
async def user_has_write_access(self, project_id: str) -> bool:
url = f'{self.BASE_URL}/projects/{project_id}'
try:
response, _ = await self._make_request(url)
# Check if the current user is in the members list with access level >= 30 (Developer)
if 'permissions' not in response:
logger.info('permissions not found', extra={'response': response})
return False
permissions = response['permissions']
if permissions['project_access']:
logger.info('[GitLab]: Checking project access')
return permissions['project_access']['access_level'] >= 30
if permissions['group_access']:
logger.info('[GitLab]: Checking group access')
return permissions['group_access']['access_level'] >= 30
return False
except Exception:
logger.warning('Access check failed', exc_info=True)
return False
async def reply_to_issue(
self, project_id: str, issue_number: str, discussion_id: str | None, body: str
):
"""
Either create new comment thread, or reply to comment thread (depending on discussion_id param)
"""
try:
if discussion_id:
url = f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}/discussions/{discussion_id}/notes'
else:
url = f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}/discussions'
params = {'body': body}
await self._make_request(url=url, params=params, method=RequestMethod.POST)
except Exception as e:
logger.exception(f'[GitLab]: Reply to issue failed {e}')
async def reply_to_mr(
self, project_id: str, merge_request_iid: str, discussion_id: str, body: str
):
"""
Reply to comment thread on MR
"""
try:
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{merge_request_iid}/discussions/{discussion_id}/notes'
params = {'body': body}
await self._make_request(url=url, params=params, method=RequestMethod.POST)
except Exception as e:
logger.exception(f'[GitLab]: Reply to MR failed {e}')

View File

@@ -0,0 +1,450 @@
from dataclasses import dataclass
from integrations.models import Message
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import HOST, get_oh_labels, has_exact_mention
from jinja2 import Environment
from server.auth.token_manager import TokenManager, get_config
from storage.database import session_maker
from storage.saas_secrets_store import SaasSecretsStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.server.services.conversation_service import create_new_conversation
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
CONFIDENTIAL_NOTE = 'confidential_note'
NOTE_TYPES = ['note', CONFIDENTIAL_NOTE]
# =================================================
# SECTION: Factory to create appriorate Gitlab view
# =================================================
@dataclass
class GitlabIssue(ResolverViewInterface):
installation_id: str # Webhook installation ID for Gitlab (comes from our DB)
issue_number: int
project_id: int
full_repo_name: str
is_public_repo: bool
user_info: UserData
raw_payload: Message
conversation_id: str
should_extract: bool
send_summary_instruction: bool
title: str
description: str
previous_comments: list[Comment]
is_mr: bool
async def _load_resolver_context(self):
gitlab_service = GitLabServiceImpl(
external_auth_id=self.user_info.keycloak_user_id
)
self.previous_comments = await gitlab_service.get_issue_or_mr_comments(
str(self.project_id), self.issue_number, is_mr=self.is_mr
)
(
self.title,
self.description,
) = await gitlab_service.get_issue_or_mr_title_and_body(
str(self.project_id), self.issue_number, is_mr=self.is_mr
)
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
user_instructions_template = jinja_env.get_template('issue_prompt.j2')
await self._load_resolver_context()
user_instructions = user_instructions_template.render(
issue_number=self.issue_number,
)
conversation_instructions_template = jinja_env.get_template(
'issue_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
issue_title=self.title,
issue_body=self.description,
comments=self.previous_comments,
)
return user_instructions, conversation_instructions
async def _get_user_secrets(self):
secrets_store = SaasSecretsStore(
self.user_info.keycloak_user_id, session_maker, get_config()
)
user_secrets = await secrets_store.load()
return user_secrets.custom_secrets if user_secrets else None
async def create_new_conversation(
self, jinja_env: Environment, git_provider_tokens: PROVIDER_TOKEN_TYPE
):
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
agent_loop_info = await create_new_conversation(
user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
custom_secrets=custom_secrets,
selected_repository=self.full_repo_name,
selected_branch=None,
initial_user_msg=user_instructions,
conversation_instructions=conversation_instructions,
image_urls=None,
conversation_trigger=ConversationTrigger.RESOLVER,
replay_json=None,
)
self.conversation_id = agent_loop_info.conversation_id
return self.conversation_id
@dataclass
class GitlabIssueComment(GitlabIssue):
comment_body: str
discussion_id: str
confidential: bool
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
user_instructions_template = jinja_env.get_template('issue_prompt.j2')
await self._load_resolver_context()
user_instructions = user_instructions_template.render(
issue_comment=self.comment_body
)
conversation_instructions_template = jinja_env.get_template(
'issue_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
issue_number=self.issue_number,
issue_title=self.title,
issue_body=self.description,
comments=self.previous_comments,
)
return user_instructions, conversation_instructions
@dataclass
class GitlabMRComment(GitlabIssueComment):
branch_name: str
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
user_instructions_template = jinja_env.get_template('mr_update_prompt.j2')
await self._load_resolver_context()
user_instructions = user_instructions_template.render(
mr_comment=self.comment_body,
)
conversation_instructions_template = jinja_env.get_template(
'mr_update_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
mr_number=self.issue_number,
branch_name=self.branch_name,
mr_title=self.title,
mr_body=self.description,
comments=self.previous_comments,
)
return user_instructions, conversation_instructions
async def create_new_conversation(
self, jinja_env: Environment, git_provider_tokens: PROVIDER_TOKEN_TYPE
):
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
agent_loop_info = await create_new_conversation(
user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
custom_secrets=custom_secrets,
selected_repository=self.full_repo_name,
selected_branch=self.branch_name,
initial_user_msg=user_instructions,
conversation_instructions=conversation_instructions,
image_urls=None,
conversation_trigger=ConversationTrigger.RESOLVER,
replay_json=None,
)
self.conversation_id = agent_loop_info.conversation_id
return self.conversation_id
@dataclass
class GitlabInlineMRComment(GitlabMRComment):
file_location: str
line_number: int
async def _load_resolver_context(self):
gitlab_service = GitLabServiceImpl(
external_auth_id=self.user_info.keycloak_user_id
)
(
self.title,
self.description,
) = await gitlab_service.get_issue_or_mr_title_and_body(
str(self.project_id), self.issue_number, is_mr=self.is_mr
)
self.previous_comments = await gitlab_service.get_review_thread_comments(
str(self.project_id), self.issue_number, self.discussion_id
)
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
user_instructions_template = jinja_env.get_template('mr_update_prompt.j2')
await self._load_resolver_context()
user_instructions = user_instructions_template.render(
mr_comment=self.comment_body,
)
conversation_instructions_template = jinja_env.get_template(
'mr_update_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
mr_number=self.issue_number,
mr_title=self.title,
mr_body=self.description,
branch_name=self.branch_name,
file_location=self.file_location,
line_number=self.line_number,
comments=self.previous_comments,
)
return user_instructions, conversation_instructions
GitlabViewType = (
GitlabInlineMRComment | GitlabMRComment | GitlabIssueComment | GitlabIssue
)
class GitlabFactory:
@staticmethod
def is_labeled_issue(message: Message) -> bool:
payload = message.message['payload']
object_kind = payload.get('object_kind')
event_type = payload.get('event_type')
if object_kind == 'issue' and event_type == 'issue':
changes = payload.get('changes', {})
labels = changes.get('labels', {})
previous = labels.get('previous', [])
current = labels.get('current', [])
previous_labels = [obj['title'] for obj in previous]
current_labels = [obj['title'] for obj in current]
if OH_LABEL not in previous_labels and OH_LABEL in current_labels:
return True
return False
@staticmethod
def is_issue_comment(message: Message) -> bool:
payload = message.message['payload']
object_kind = payload.get('object_kind')
event_type = payload.get('event_type')
issue = payload.get('issue')
if object_kind == 'note' and event_type in NOTE_TYPES and issue:
comment_body = payload.get('object_attributes', {}).get('note', '')
return has_exact_mention(comment_body, INLINE_OH_LABEL)
return False
@staticmethod
def is_mr_comment(message: Message, inline=False) -> bool:
payload = message.message['payload']
object_kind = payload.get('object_kind')
event_type = payload.get('event_type')
merge_request = payload.get('merge_request')
if not (object_kind == 'note' and event_type in NOTE_TYPES and merge_request):
return False
# Check whether not belongs to MR
object_attributes = payload.get('object_attributes', {})
noteable_type = object_attributes.get('noteable_type')
if noteable_type != 'MergeRequest':
return False
# Check whether comment is inline
change_position = object_attributes.get('change_position')
if inline and not change_position:
return False
if not inline and change_position:
return False
# Check body
comment_body = object_attributes.get('note', '')
return has_exact_mention(comment_body, INLINE_OH_LABEL)
@staticmethod
def determine_if_confidential(event_type: str):
return event_type == CONFIDENTIAL_NOTE
@staticmethod
async def create_gitlab_view_from_payload(
message: Message, token_manager: TokenManager
) -> ResolverViewInterface:
payload = message.message['payload']
installation_id = message.message['installation_id']
user = payload['user']
user_id = user['id']
username = user['username']
repo_obj = payload['project']
selected_project = repo_obj['path_with_namespace']
is_public_repo = repo_obj['visibility_level'] == 0
project_id = payload['object_attributes']['project_id']
keycloak_user_id = await token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITLAB
)
user_info = UserData(
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
)
if GitlabFactory.is_labeled_issue(message):
issue_iid = payload['object_attributes']['iid']
logger.info(
f'[GitLab] Creating view for labeled issue from {username} in {selected_project}#{issue_iid}'
)
return GitlabIssue(
installation_id=installation_id,
issue_number=issue_iid,
project_id=project_id,
full_repo_name=selected_project,
is_public_repo=is_public_repo,
user_info=user_info,
raw_payload=message,
conversation_id='',
should_extract=True,
send_summary_instruction=True,
title='',
description='',
previous_comments=[],
is_mr=False,
)
elif GitlabFactory.is_issue_comment(message):
event_type = payload['event_type']
issue_iid = payload['issue']['iid']
object_attributes = payload['object_attributes']
discussion_id = object_attributes['discussion_id']
comment_body = object_attributes['note']
logger.info(
f'[GitLab] Creating view for issue comment from {username} in {selected_project}#{issue_iid}'
)
return GitlabIssueComment(
installation_id=installation_id,
comment_body=comment_body,
issue_number=issue_iid,
discussion_id=discussion_id,
project_id=project_id,
confidential=GitlabFactory.determine_if_confidential(event_type),
full_repo_name=selected_project,
is_public_repo=is_public_repo,
user_info=user_info,
raw_payload=message,
conversation_id='',
should_extract=True,
send_summary_instruction=True,
title='',
description='',
previous_comments=[],
is_mr=False,
)
elif GitlabFactory.is_mr_comment(message):
event_type = payload['event_type']
merge_request_iid = payload['merge_request']['iid']
branch_name = payload['merge_request']['source_branch']
object_attributes = payload['object_attributes']
discussion_id = object_attributes['discussion_id']
comment_body = object_attributes['note']
logger.info(
f'[GitLab] Creating view for merge request comment from {username} in {selected_project}#{merge_request_iid}'
)
return GitlabMRComment(
installation_id=installation_id,
comment_body=comment_body,
issue_number=merge_request_iid, # Using issue_number as mr_number for compatibility
discussion_id=discussion_id,
project_id=project_id,
full_repo_name=selected_project,
is_public_repo=is_public_repo,
user_info=user_info,
raw_payload=message,
conversation_id='',
should_extract=True,
send_summary_instruction=True,
confidential=GitlabFactory.determine_if_confidential(event_type),
branch_name=branch_name,
title='',
description='',
previous_comments=[],
is_mr=True,
)
elif GitlabFactory.is_mr_comment(message, inline=True):
event_type = payload['event_type']
merge_request_iid = payload['merge_request']['iid']
branch_name = payload['merge_request']['source_branch']
object_attributes = payload['object_attributes']
comment_body = object_attributes['note']
position_info = object_attributes['position']
discussion_id = object_attributes['discussion_id']
file_location = object_attributes['position']['new_path']
line_number = (
position_info.get('new_line') or position_info.get('old_line') or 0
)
logger.info(
f'[GitLab] Creating view for inline merge request comment from {username} in {selected_project}#{merge_request_iid}'
)
return GitlabInlineMRComment(
installation_id=installation_id,
issue_number=merge_request_iid, # Using issue_number as mr_number for compatibility
discussion_id=discussion_id,
project_id=project_id,
full_repo_name=selected_project,
is_public_repo=is_public_repo,
user_info=user_info,
raw_payload=message,
conversation_id='',
should_extract=True,
send_summary_instruction=True,
confidential=GitlabFactory.determine_if_confidential(event_type),
branch_name=branch_name,
file_location=file_location,
line_number=line_number,
comment_body=comment_body,
title='',
description='',
previous_comments=[],
is_mr=True,
)

View File

@@ -0,0 +1,503 @@
import hashlib
import hmac
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
import httpx
from fastapi import Request
from integrations.jira.jira_types import JiraViewInterface
from integrations.jira.jira_view import (
JiraExistingConversationView,
JiraFactory,
JiraNewConversationView,
)
from integrations.manager import Manager
from integrations.models import JobContext, Message
from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.server.user_auth.user_auth import UserAuth
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
class JiraManager(Manager):
def __init__(self, token_manager: TokenManager):
self.token_manager = token_manager
self.integration_store = JiraIntegrationStore.get_instance()
self.jinja_env = Environment(
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'jira')
)
async def authenticate_user(
self, jira_user_id: str, workspace_id: int
) -> tuple[JiraUser | None, UserAuth | None]:
"""Authenticate Jira user and get their OpenHands user auth."""
# Find active Jira user by Keycloak user ID and workspace ID
jira_user = await self.integration_store.get_active_user(
jira_user_id, workspace_id
)
if not jira_user:
logger.warning(
f'[Jira] No active Jira user found for {jira_user_id} in workspace {workspace_id}'
)
return None, None
saas_user_auth = await get_user_auth_from_keycloak_id(
jira_user.keycloak_user_id
)
return jira_user, saas_user_auth
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
"""Get repositories that the user has access to."""
provider_tokens = await user_auth.get_provider_tokens()
if provider_tokens is None:
return []
access_token = await user_auth.get_access_token()
user_id = await user_auth.get_user_id()
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
repos: list[Repository] = await client.get_repositories(
'pushed', server_config.app_mode, None, None, None, None
)
return repos
async def validate_request(
self, request: Request
) -> Tuple[bool, Optional[str], Optional[Dict]]:
"""Verify Jira webhook signature."""
signature_header = request.headers.get('x-hub-signature')
signature = signature_header.split('=')[1] if signature_header else None
body = await request.body()
payload = await request.json()
workspace_name = ''
if payload.get('webhookEvent') == 'comment_created':
selfUrl = payload.get('comment', {}).get('author', {}).get('self')
elif payload.get('webhookEvent') == 'jira:issue_updated':
selfUrl = payload.get('user', {}).get('self')
else:
workspace_name = ''
parsedUrl = urlparse(selfUrl)
if parsedUrl.hostname:
workspace_name = parsedUrl.hostname
if not workspace_name:
logger.warning('[Jira] No workspace name found in webhook payload')
return False, None, None
if not signature:
logger.warning('[Jira] No signature found in webhook headers')
return False, None, None
workspace = await self.integration_store.get_workspace_by_name(workspace_name)
if not workspace:
logger.warning('[Jira] Could not identify workspace for webhook')
return False, None, None
if workspace.status != 'active':
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
return False, None, None
webhook_secret = self.token_manager.decrypt_text(workspace.webhook_secret)
digest = hmac.new(webhook_secret.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, digest):
logger.info('[Jira] Webhook signature verified successfully')
return True, signature, payload
return False, None, None
def parse_webhook(self, payload: Dict) -> JobContext | None:
event_type = payload.get('webhookEvent')
if event_type == 'comment_created':
comment_data = payload.get('comment', {})
comment = comment_data.get('body', '')
if '@openhands' not in comment:
return None
issue_data = payload.get('issue', {})
issue_id = issue_data.get('id')
issue_key = issue_data.get('key')
base_api_url = issue_data.get('self', '').split('/rest/')[0]
user_data = comment_data.get('author', {})
user_email = user_data.get('emailAddress')
display_name = user_data.get('displayName')
account_id = user_data.get('accountId')
elif event_type == 'jira:issue_updated':
changelog = payload.get('changelog', {})
items = changelog.get('items', [])
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
if 'openhands' not in labels:
return None
issue_data = payload.get('issue', {})
issue_id = issue_data.get('id')
issue_key = issue_data.get('key')
base_api_url = issue_data.get('self', '').split('/rest/')[0]
user_data = payload.get('user', {})
user_email = user_data.get('emailAddress')
display_name = user_data.get('displayName')
account_id = user_data.get('accountId')
comment = ''
else:
return None
workspace_name = ''
parsedUrl = urlparse(base_api_url)
if parsedUrl.hostname:
workspace_name = parsedUrl.hostname
if not all(
[
issue_id,
issue_key,
user_email,
display_name,
account_id,
workspace_name,
base_api_url,
]
):
return None
return JobContext(
issue_id=issue_id,
issue_key=issue_key,
user_msg=comment,
user_email=user_email,
display_name=display_name,
platform_user_id=account_id,
workspace_name=workspace_name,
base_api_url=base_api_url,
)
async def receive_message(self, message: Message):
"""Process incoming Jira webhook message."""
payload = message.message.get('payload', {})
job_context = self.parse_webhook(payload)
if not job_context:
logger.info('[Jira] Webhook does not match trigger conditions')
return
# Get workspace by user email domain
workspace = await self.integration_store.get_workspace_by_name(
job_context.workspace_name
)
if not workspace:
logger.warning(
f'[Jira] No workspace found for email domain: {job_context.user_email}'
)
await self._send_error_comment(
job_context,
'Your workspace is not configured with Jira integration.',
None,
)
return
# Prevent any recursive triggers from the service account
if job_context.user_email == workspace.svc_acc_email:
return
if workspace.status != 'active':
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
await self._send_error_comment(
job_context,
'Jira integration is not active for your workspace.',
workspace,
)
return
# Authenticate user
jira_user, saas_user_auth = await self.authenticate_user(
job_context.platform_user_id, workspace.id
)
if not jira_user or not saas_user_auth:
logger.warning(
f'[Jira] User authentication failed for {job_context.user_email}'
)
await self._send_error_comment(
job_context,
f'User {job_context.user_email} is not authenticated or active in the Jira integration.',
workspace,
)
return
# Get issue details
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
issue_title, issue_description = await self.get_issue_details(
job_context, workspace.jira_cloud_id, workspace.svc_acc_email, api_key
)
job_context.issue_title = issue_title
job_context.issue_description = issue_description
except Exception as e:
logger.error(f'[Jira] Failed to get issue context: {str(e)}')
await self._send_error_comment(
job_context,
'Failed to retrieve issue details. Please check the issue key and try again.',
workspace,
)
return
try:
# Create Jira view
jira_view = await JiraFactory.create_jira_view_from_payload(
job_context,
saas_user_auth,
jira_user,
workspace,
)
except Exception as e:
logger.error(f'[Jira] Failed to create jira view: {str(e)}', exc_info=True)
await self._send_error_comment(
job_context,
'Failed to initialize conversation. Please try again.',
workspace,
)
return
if not await self.is_job_requested(message, jira_view):
return
await self.start_job(jira_view)
async def is_job_requested(
self, message: Message, jira_view: JiraViewInterface
) -> bool:
"""
Check if a job is requested and handle repository selection.
"""
if isinstance(jira_view, JiraExistingConversationView):
return True
try:
# Get user repositories
user_repos: list[Repository] = await self._get_repositories(
jira_view.saas_user_auth
)
target_str = f'{jira_view.job_context.issue_description}\n{jira_view.job_context.user_msg}'
# Try to infer repository from issue description
match, repos = filter_potential_repos_by_user_msg(target_str, user_repos)
if match:
# Found exact repository match
jira_view.selected_repo = repos[0].full_name
logger.info(f'[Jira] Inferred repository: {repos[0].full_name}')
return True
else:
# No clear match - send repository selection comment
await self._send_repo_selection_comment(jira_view)
return False
except Exception as e:
logger.error(f'[Jira] Error in is_job_requested: {str(e)}')
return False
async def start_job(self, jira_view: JiraViewInterface):
"""Start a Jira job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.jira_callback_processor import (
JiraCallbackProcessor,
)
try:
user_info: JiraUser = jira_view.jira_user
logger.info(
f'[Jira] Starting job for user {user_info.keycloak_user_id} '
f'issue {jira_view.job_context.issue_key}',
)
# Create conversation
conversation_id = await jira_view.create_or_update_conversation(
self.jinja_env
)
logger.info(
f'[Jira] Created/Updated conversation {conversation_id} for issue {jira_view.job_context.issue_key}'
)
# Register callback processor for updates
if isinstance(jira_view, JiraNewConversationView):
processor = JiraCallbackProcessor(
issue_key=jira_view.job_context.issue_key,
workspace_name=jira_view.jira_workspace.name,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Jira] Created callback processor for conversation {conversation_id}'
)
# Send initial response
msg_info = jira_view.get_response_msg()
except MissingSettingsError as e:
logger.warning(f'[Jira] Missing settings error: {str(e)}')
msg_info = f'Please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
except LLMAuthenticationError as e:
logger.warning(f'[Jira] LLM authentication error: {str(e)}')
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except Exception as e:
logger.error(
f'[Jira] Unexpected error starting job: {str(e)}', exc_info=True
)
msg_info = 'Sorry, there was an unexpected error starting the job. Please try again.'
# Send response comment
try:
api_key = self.token_manager.decrypt_text(
jira_view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg_info),
issue_key=jira_view.job_context.issue_key,
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(f'[Jira] Failed to send response message: {str(e)}')
async def get_issue_details(
self,
job_context: JobContext,
jira_cloud_id: str,
svc_acc_email: str,
svc_acc_api_key: str,
) -> Tuple[str, str]:
url = f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{job_context.issue_key}'
async with httpx.AsyncClient() as client:
response = await client.get(url, auth=(svc_acc_email, svc_acc_api_key))
response.raise_for_status()
issue_payload = response.json()
if not issue_payload:
raise ValueError(f'Issue with key {job_context.issue_key} not found.')
title = issue_payload.get('fields', {}).get('summary', '')
description = issue_payload.get('fields', {}).get('description', '')
if not title:
raise ValueError(
f'Issue with key {job_context.issue_key} does not have a title.'
)
if not description:
raise ValueError(
f'Issue with key {job_context.issue_key} does not have a description.'
)
return title, description
async def send_message(
self,
message: Message,
issue_key: str,
jira_cloud_id: str,
svc_acc_email: str,
svc_acc_api_key: str,
):
url = (
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
)
data = {'body': message.message}
async with httpx.AsyncClient() as client:
response = await client.post(
url, auth=(svc_acc_email, svc_acc_api_key), json=data
)
response.raise_for_status()
return response.json()
async def _send_error_comment(
self,
job_context: JobContext,
error_msg: str,
workspace: JiraWorkspace | None,
):
"""Send error comment to Jira issue."""
if not workspace:
logger.error('[Jira] Cannot send error comment - no workspace available')
return
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
await self.send_message(
self.create_outgoing_message(msg=error_msg),
issue_key=job_context.issue_key,
jira_cloud_id=workspace.jira_cloud_id,
svc_acc_email=workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(f'[Jira] Failed to send error comment: {str(e)}')
async def _send_repo_selection_comment(self, jira_view: JiraViewInterface):
"""Send a comment with repository options for the user to choose."""
try:
comment_msg = (
'I need to know which repository to work with. '
'Please add it to your issue description or send a followup comment.'
)
api_key = self.token_manager.decrypt_text(
jira_view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=comment_msg),
issue_key=jira_view.job_context.issue_key,
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
logger.info(
f'[Jira] Sent repository selection comment for issue {jira_view.job_context.issue_key}'
)
except Exception as e:
logger.error(
f'[Jira] Failed to send repository selection comment: {str(e)}'
)

View File

@@ -0,0 +1,40 @@
from abc import ABC, abstractmethod
from integrations.models import JobContext
from jinja2 import Environment
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.server.user_auth.user_auth import UserAuth
class JiraViewInterface(ABC):
"""Interface for Jira views that handle different types of Jira interactions."""
job_context: JobContext
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str | None
conversation_id: str
@abstractmethod
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get initial instructions for the conversation."""
pass
@abstractmethod
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create or update a conversation and return the conversation ID."""
pass
@abstractmethod
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira."""
pass
class StartingConvoException(Exception):
"""Exception raised when starting a conversation fails."""
pass

View File

@@ -0,0 +1,222 @@
from dataclasses import dataclass
from integrations.jira.jira_types import JiraViewInterface, StartingConvoException
from integrations.models import JobContext
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
from storage.jira_conversation import JiraConversation
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
integration_store = JiraIntegrationStore.get_instance()
@dataclass
class JiraNewConversationView(JiraViewInterface):
job_context: JobContext
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str | None
conversation_id: str
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
instructions_template = jinja_env.get_template('jira_instructions.j2')
instructions = instructions_template.render()
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
user_message=self.job_context.user_msg or '',
)
return instructions, user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create a new Jira conversation"""
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_user_secrets()
instructions, user_msg = self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
user_id=self.jira_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.JIRA,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
self.conversation_id = agent_loop_info.conversation_id
logger.info(f'[Jira] Created conversation {self.conversation_id}')
# Store Jira conversation mapping
jira_conversation = JiraConversation(
conversation_id=self.conversation_id,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
jira_user_id=self.jira_user.id,
)
await integration_store.create_conversation(jira_conversation)
return self.conversation_id
except Exception as e:
logger.error(
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [track my progress here|{conversation_link}]."
@dataclass
class JiraExistingConversationView(JiraViewInterface):
job_context: JobContext
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str | None
conversation_id: str
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
user_msg_template = jinja_env.get_template('jira_existing_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
user_message=self.job_context.user_msg or '',
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
)
return '', user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Update an existing Jira conversation"""
user_id = self.jira_user.keycloak_user_id
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
metadata = await conversation_store.get_metadata(self.conversation_id)
if not metadata:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
# Should we raise here if there are no providers?
providers_set = list(provider_tokens.keys()) if provider_tokens else []
conversation_init_data = await setup_init_conversation_settings(
user_id, self.conversation_id, providers_set
)
# Either join ongoing conversation, or restart the conversation
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
self.conversation_id, conversation_init_data, user_id
)
final_agent_observation = get_final_agent_observation(
agent_loop_info.event_store
)
agent_state = (
None
if len(final_agent_observation) == 0
else final_agent_observation[0].agent_state
)
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
_, user_msg = self._get_instructions(jinja_env)
user_message_event = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_message_event)
)
return self.conversation_id
except Exception as e:
logger.error(
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here|{conversation_link}]."
class JiraFactory:
"""Factory for creating Jira views based on message content"""
@staticmethod
async def create_jira_view_from_payload(
job_context: JobContext,
saas_user_auth: UserAuth,
jira_user: JiraUser,
jira_workspace: JiraWorkspace,
) -> JiraViewInterface:
"""Create appropriate Jira view based on the message and user state"""
if not jira_user or not saas_user_auth or not jira_workspace:
raise StartingConvoException('User not authenticated with Jira integration')
conversation = await integration_store.get_user_conversations_by_issue_id(
job_context.issue_id, jira_user.id
)
if conversation:
logger.info(
f'[Jira] Found existing conversation for issue {job_context.issue_id}'
)
return JiraExistingConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
jira_user=jira_user,
jira_workspace=jira_workspace,
selected_repo=None,
conversation_id=conversation.conversation_id,
)
return JiraNewConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
jira_user=jira_user,
jira_workspace=jira_workspace,
selected_repo=None, # Will be set later after repo inference
conversation_id='', # Will be set when conversation is created
)

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