mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
30 Commits
alona/fix-
...
hieptl/deb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bbf93aeb2 | ||
|
|
c932cd0815 | ||
|
|
d3395172f8 | ||
|
|
1e8851b244 | ||
|
|
167fb3f429 | ||
|
|
df4d30addf | ||
|
|
37daf068c5 | ||
|
|
5452abe513 | ||
|
|
a8b6406dac | ||
|
|
509d4a9513 | ||
|
|
d099c21f5d | ||
|
|
4c89b5ad91 | ||
|
|
729c181313 | ||
|
|
2eb3a9e6ad | ||
|
|
2382baacc2 | ||
|
|
98ce55e2fc | ||
|
|
c929447624 | ||
|
|
2cc77fb034 | ||
|
|
d57462e8ca | ||
|
|
1e23017bb1 | ||
|
|
3493348fac | ||
|
|
e63d981192 | ||
|
|
e19b3dd1f0 | ||
|
|
c3da6c20bd | ||
|
|
a022f505a8 | ||
|
|
04196f8d53 | ||
|
|
dcf00c34fa | ||
|
|
d4e94b32e1 | ||
|
|
a1b81fe923 | ||
|
|
e6d799c51a |
4
.github/workflows/ghcr-build.yml
vendored
4
.github/workflows/ghcr-build.yml
vendored
@@ -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
|
||||
@@ -136,6 +137,7 @@ 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
|
||||
|
||||
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
|
||||
@@ -211,6 +213,8 @@ jobs:
|
||||
latest=auto
|
||||
prefix=
|
||||
suffix=
|
||||
env:
|
||||
DOCKER_METADATA_PR_HEAD_SHA: true
|
||||
- name: Determine app image tag
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -45,7 +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](dub.sh/openhands). 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" +
|
||||
"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 -->"
|
||||
});
|
||||
|
||||
@@ -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. Let’s 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. Let’s 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 it’s 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 it’s too busy, but set notifications to alert you only when “LLMs” appears in messages.
|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
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.57-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.58-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -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://dub.sh/openhands"><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.
|
||||
|
||||

|
||||
|
||||
## ☁️ 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.57-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.57-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.57
|
||||
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
|
||||
@@ -140,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://dub.sh/openhands) - 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).
|
||||
|
||||
148
README_CN.md
148
README_CN.md
@@ -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://dub.sh/openhands"><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)
|
||||
> 加入我们的设计合作伙伴计划,您将获得商业功能的早期访问权限,并有机会对我们的产品路线图提供意见。
|
||||
|
||||

|
||||
|
||||
## ☁️ 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.57-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-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.57
|
||||
```
|
||||
|
||||
> **注意**: 如果您在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://dub.sh/openhands) - 这里我们讨论研究、架构和未来发展。
|
||||
- [加入我们的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},
|
||||
}
|
||||
```
|
||||
60
README_JA.md
60
README_JA.md
@@ -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://dub.sh/openhands"><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プログラムにご参加ください。商用機能の早期アクセスや製品ロードマップへのフィードバックの機会を提供します。
|
||||
|
||||

|
||||
|
||||
## ☁️ 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.57-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-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.57
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
OpenHandsは[http://localhost:3000](http://localhost:3000)で起動します!
|
||||
@@ -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,7 +9,7 @@ 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
|
||||
|
||||
@@ -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.57-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:
|
||||
|
||||
@@ -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.57-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:
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"slack": "https://dub.sh/openhands",
|
||||
"slack": "https://all-hands.dev/joinslack",
|
||||
"github": "https://github.com/All-Hands-AI/OpenHands",
|
||||
"discord": "https://discord.gg/ESHStjSjD4"
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ Settings are divided across tabs, with each tab focusing on a specific area of c
|
||||
- Toggle Solvability Analysis.
|
||||
- Set a maximum budget per conversation.
|
||||
- Configure the username and email that OpenHands uses for commits.
|
||||
- `LLM` (Available for `Pro Users`)
|
||||
- `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.
|
||||
- Cancel your `Pro subscription`.
|
||||
- `Secrets`
|
||||
- [Generate custom secrets](/usage/settings/secrets-settings).
|
||||
- [Manage secrets](/usage/settings/secrets-settings).
|
||||
- `API Keys`
|
||||
- [Create API keys to work with OpenHands programmatically](/usage/cloud/cloud-api).
|
||||
- `MCP`
|
||||
|
||||
@@ -3,24 +3,27 @@ title: "Pro Subscription"
|
||||
description: "Learn about OpenHands Cloud Pro Subscription features and pricing"
|
||||
---
|
||||
|
||||
The OpenHands Pro Subscription unlocks additional features and better pricing when you run OpenHands conversations in OpenHands Cloud.
|
||||
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 tools like OpenHands CLI or OpenHands Local GUI**
|
||||
* **$20 in initial OpenHands Cloud credits to get started**
|
||||
* **Support for GitHub, GitLab, Bitbucket, Slack, and more**
|
||||
* **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:
|
||||
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.
|
||||
* **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
|
||||
|
||||
@@ -33,7 +36,7 @@ Here are the key differences between Pay-as-you-go and Pro subscriptions:
|
||||
| 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.* |
|
||||
| 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
|
||||
@@ -42,4 +45,4 @@ The following applies to **both** the Pay-as-you-go and 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, OpenHands Local GUI, 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)* |
|
||||
| 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)* |
|
||||
|
||||
@@ -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.57-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.57 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Set environment variables and run the Docker command:
|
||||
```bash
|
||||
# Set required environment variables
|
||||
export SANDBOX_VOLUMES="/path/to/workspace:/workspace:rw" # Format: host_path:container_path:mode
|
||||
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
|
||||
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.57-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.57 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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.57-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.57-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.57
|
||||
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.57
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -36,6 +36,7 @@ Pricing follows official API provider rates. Below are the current pricing detai
|
||||
|-------|----------------------------|-----------------------------------|------------------------------|------------------|-------------------|
|
||||
| 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 |
|
||||
|
||||
@@ -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.57-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.57-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.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -27,7 +27,7 @@ repos:
|
||||
- id: ruff
|
||||
entry: ruff check --config enterprise/dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix, --show-fixes]
|
||||
args: [--fix]
|
||||
files: ^enterprise/
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
|
||||
@@ -227,23 +227,12 @@ class SaasUserAuth(UserAuth):
|
||||
def get_api_key_from_header(request: Request):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
api_key = auth_header.replace('Bearer ', '')
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Got API key from Authorization header: '
|
||||
f'key_preview={api_key[:10] if api_key else "None"}...'
|
||||
)
|
||||
return api_key
|
||||
return auth_header.replace('Bearer ', '')
|
||||
|
||||
# This is a temp hack
|
||||
# Streamable HTTP MCP Client works via redirect requests, but drops the Authorization header for reason
|
||||
# We include `X-Session-API-Key` header by default due to nested runtimes, so it used as a drop in replacement here
|
||||
session_api_key = request.headers.get('X-Session-API-Key')
|
||||
if session_api_key:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Got API key from X-Session-API-Key header: '
|
||||
f'key_preview={session_api_key[:10] if session_api_key else "None"}...'
|
||||
)
|
||||
return session_api_key
|
||||
return request.headers.get('X-Session-API-Key')
|
||||
|
||||
|
||||
async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
|
||||
@@ -270,11 +259,7 @@ async def saas_user_auth_from_cookie(request: Request) -> SaasUserAuth | None:
|
||||
try:
|
||||
signed_token = request.cookies.get('keycloak_auth')
|
||||
if not signed_token:
|
||||
logger.info('[TOKEN_DEBUG] No keycloak_auth cookie found in request')
|
||||
return None
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Found keycloak_auth cookie, size={len(signed_token)}'
|
||||
)
|
||||
return await saas_user_auth_from_signed_token(signed_token)
|
||||
except Exception as exc:
|
||||
raise CookieError from exc
|
||||
@@ -287,10 +272,6 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
|
||||
logger.debug('saas_user_auth_from_signed_token:decoded')
|
||||
access_token = decoded['access_token']
|
||||
refresh_token = decoded['refresh_token']
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Cookie tokens: '
|
||||
f'refresh_token_preview={refresh_token[:20] if refresh_token else "None"}...'
|
||||
)
|
||||
logger.debug(
|
||||
'saas_user_auth_from_signed_token',
|
||||
extra={
|
||||
@@ -306,35 +287,6 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
|
||||
user_id = access_token_payload['sub']
|
||||
email = access_token_payload['email']
|
||||
email_verified = access_token_payload['email_verified']
|
||||
|
||||
# Check if we have an offline token in the database
|
||||
logger.info(f'[TOKEN_DEBUG] Checking for offline token for user {user_id}')
|
||||
try:
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
if offline_token:
|
||||
# Compare tokens definitively
|
||||
tokens_match = offline_token == refresh_token
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Token comparison: '
|
||||
f'TOKENS_ARE_{"SAME" if tokens_match else "DIFFERENT"}! '
|
||||
f'Cookie len={len(refresh_token) if refresh_token else 0}, '
|
||||
f'DB len={len(offline_token) if offline_token else 0}'
|
||||
)
|
||||
if not tokens_match:
|
||||
# Log first 50 chars for better comparison
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Cookie token: {refresh_token[:50] if refresh_token else "None"}...'
|
||||
)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] DB offline token: {offline_token[:50] if offline_token else "None"}...'
|
||||
)
|
||||
# TODO: Consider using offline_token instead of refresh_token
|
||||
# refresh_token = offline_token
|
||||
else:
|
||||
logger.info('[TOKEN_DEBUG] No offline token in DB, using cookie token')
|
||||
except Exception as e:
|
||||
logger.error(f'[TOKEN_DEBUG] Error loading offline token: {e}')
|
||||
|
||||
logger.debug('saas_user_auth_from_signed_token:return')
|
||||
|
||||
return SaasUserAuth(
|
||||
@@ -352,11 +304,6 @@ async def get_user_auth_from_keycloak_id(keycloak_user_id: str) -> UserAuth:
|
||||
offline_token = await token_manager.load_offline_token(keycloak_user_id)
|
||||
if offline_token is None:
|
||||
logger.info('no_offline_token_found')
|
||||
else:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Using offline token from DB for user {keycloak_user_id}: '
|
||||
f'{offline_token[:20] if offline_token else "None"}...'
|
||||
)
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
user_id=keycloak_user_id,
|
||||
|
||||
@@ -266,10 +266,6 @@ class TokenManager:
|
||||
user_id = user_info.get('sub')
|
||||
username = user_info.get('preferred_username')
|
||||
logger.info(f'Getting token for user {username} and IDP {idp}')
|
||||
logger.info(
|
||||
'[TOKEN_SOURCE_DEBUG] get_idp_token called with access_token '
|
||||
'(from cookie/request), will check DB for stored tokens'
|
||||
)
|
||||
token_store = await AuthTokenStore.get_instance(
|
||||
keycloak_user_id=user_id, idp=idp
|
||||
)
|
||||
@@ -443,16 +439,8 @@ class TokenManager:
|
||||
0 if refresh_expires_in == 0 else current_time + refresh_expires_in
|
||||
)
|
||||
|
||||
# Log detailed expiration info for debugging
|
||||
access_expires_hours = expires_in / 3600 if expires_in > 0 else 'Never'
|
||||
refresh_expires_days = (
|
||||
refresh_expires_in / 86400 if refresh_expires_in > 0 else 'Never'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Token refresh successful. Access token expires in: {access_expires_hours} hours, '
|
||||
f'Refresh token expires in: {refresh_expires_days} days. '
|
||||
f'Raw values - expires_in: {expires_in}s, refresh_expires_in: {refresh_expires_in}s'
|
||||
f'Token refresh successful. New access token expires at: {access_token_expires_at}, refresh token expires at: {refresh_token_expires_at}'
|
||||
)
|
||||
return {
|
||||
'access_token': access_token,
|
||||
@@ -469,32 +457,15 @@ class TokenManager:
|
||||
async def get_idp_token_from_offline_token(
|
||||
self, offline_token: str, idp: ProviderType
|
||||
) -> str:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Getting {idp} token from offline token. '
|
||||
f'Token preview: {offline_token[:20] if offline_token else "None"}...'
|
||||
)
|
||||
logger.info(
|
||||
f'[TOKEN_SOURCE_DEBUG] Using OFFLINE token (from DB) to refresh {idp} token, '
|
||||
f'token_length={len(offline_token) if offline_token else 0}'
|
||||
)
|
||||
logger.info('Getting IDP token from offline token')
|
||||
|
||||
try:
|
||||
logger.info('[TOKEN_DEBUG] Calling Keycloak to refresh offline token...')
|
||||
tokens = await get_keycloak_openid(self.external).a_refresh_token(
|
||||
offline_token
|
||||
)
|
||||
logger.info('[TOKEN_DEBUG] Keycloak refresh successful!')
|
||||
return await self.get_idp_token(tokens['access_token'], idp)
|
||||
except KeycloakConnectionError as e:
|
||||
logger.error(
|
||||
f'[TOKEN_DEBUG] KeycloakConnectionError when refreshing token: {e}'
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[TOKEN_DEBUG] Unexpected error refreshing Keycloak token: '
|
||||
f'{type(e).__name__}: {e}'
|
||||
)
|
||||
except KeycloakConnectionError:
|
||||
logger.exception('KeycloakConnectionError when refreshing token')
|
||||
raise
|
||||
|
||||
@retry(
|
||||
|
||||
@@ -21,6 +21,7 @@ from openhands.events.event_store_abc import EventStoreABC
|
||||
from openhands.events.observation import AgentStateChangedObservation
|
||||
from openhands.events.stream import EventStreamSubscriber
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
ConversationManager,
|
||||
@@ -686,6 +687,7 @@ class ClusteredConversationManager(StandaloneConversationManager):
|
||||
url=self._get_conversation_url(conversation_id),
|
||||
session_api_key=None,
|
||||
event_store=EventStore(conversation_id, self.file_store, uid),
|
||||
runtime_status=RuntimeStatus.READY,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -8,7 +8,6 @@ from server.clustered_conversation_manager import ClusteredConversationManager
|
||||
from server.saas_nested_conversation_manager import SaasNestedConversationManager
|
||||
|
||||
from openhands.core.config import LLMConfig, OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
@@ -34,8 +33,8 @@ class LegacyCacheEntry:
|
||||
|
||||
@dataclass
|
||||
class LegacyConversationManager(ConversationManager):
|
||||
"""Conversation manager for use while migrating - since existing conversations are not nested.
|
||||
|
||||
"""
|
||||
Conversation manager for use while migrating - since existing conversations are not nested!
|
||||
Separate class from SaasNestedConversationManager so it can be easliy removed in a few weeks.
|
||||
(As of 2025-07-23)
|
||||
"""
|
||||
@@ -188,33 +187,10 @@ class LegacyConversationManager(ConversationManager):
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
try:
|
||||
has_tokens = bool(
|
||||
settings
|
||||
and hasattr(settings, 'provider_tokens')
|
||||
and settings.provider_tokens
|
||||
)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] LegacyManager.maybe_start_agent_loop ENTRY: '
|
||||
f'sid={sid}, user_id={user_id}, has_provider_tokens={has_tokens}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[TOKEN_DEBUG] Error logging entry: {e}')
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] LegacyManager.maybe_start_agent_loop ENTRY: sid={sid}, user_id={user_id}'
|
||||
)
|
||||
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] LegacyManager: Routing {sid} to ClusteredConversationManager (legacy mode)'
|
||||
)
|
||||
return await self.legacy_conversation_manager.maybe_start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] LegacyManager: Routing {sid} to SaasNestedConversationManager (new mode)'
|
||||
)
|
||||
return await self.conversation_manager.maybe_start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
@@ -294,8 +270,8 @@ class LegacyConversationManager(ConversationManager):
|
||||
del self._legacy_cache[key]
|
||||
|
||||
async def should_start_in_legacy_mode(self, conversation_id: str) -> bool:
|
||||
"""Check if a conversation should run in legacy mode by directly checking the runtime.
|
||||
|
||||
"""
|
||||
Check if a conversation should run in legacy mode by directly checking the runtime.
|
||||
The /list method does not include stopped conversations even though the PVC for these
|
||||
may not yet have been deleted, so we need to check /sessions/{session_id} directly.
|
||||
"""
|
||||
@@ -307,32 +283,11 @@ class LegacyConversationManager(ConversationManager):
|
||||
cached_entry = self._legacy_cache[conversation_id]
|
||||
# Check if the cached value is still valid
|
||||
if time.time() - cached_entry.timestamp <= _LEGACY_ENTRY_TIMEOUT_SECONDS:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] LegacyManager: Using cached legacy status for {conversation_id}: '
|
||||
f'is_legacy={cached_entry.is_legacy}'
|
||||
)
|
||||
return cached_entry.is_legacy
|
||||
|
||||
# If not in cache or expired, check the runtime directly
|
||||
runtime = await self.conversation_manager._get_runtime(conversation_id)
|
||||
|
||||
# Log runtime details for debugging
|
||||
if runtime:
|
||||
logger.info(
|
||||
f"[TOKEN_DEBUG] LegacyManager: Runtime check for {conversation_id}: "
|
||||
f"status={runtime.get('status')}, has_command={bool(runtime.get('command'))}, "
|
||||
f"command_preview={str(runtime.get('command', ''))[:100]}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] LegacyManager: No runtime found for {conversation_id}'
|
||||
)
|
||||
|
||||
is_legacy = self.is_legacy_runtime(runtime)
|
||||
logger.info(
|
||||
f"[TOKEN_DEBUG] LegacyManager: Determined legacy status for {conversation_id}: "
|
||||
f"is_legacy={is_legacy}, will use {'ClusteredConversationManager' if is_legacy else 'SaasNestedConversationManager'}"
|
||||
)
|
||||
|
||||
# Cache the result with current timestamp
|
||||
self._legacy_cache[conversation_id] = LegacyCacheEntry(is_legacy, time.time())
|
||||
@@ -340,7 +295,8 @@ class LegacyConversationManager(ConversationManager):
|
||||
return is_legacy
|
||||
|
||||
def is_legacy_runtime(self, runtime: dict | None) -> bool:
|
||||
"""Determine if a runtime is a legacy runtime based on its command.
|
||||
"""
|
||||
Determine if a runtime is a legacy runtime based on its command.
|
||||
|
||||
Args:
|
||||
runtime: The runtime dictionary or None if not found
|
||||
@@ -348,25 +304,9 @@ class LegacyConversationManager(ConversationManager):
|
||||
Returns:
|
||||
bool: True if this is a legacy runtime, False otherwise
|
||||
"""
|
||||
# Ensure runtime is actually a dict (not None, mock, or other object)
|
||||
if not isinstance(runtime, dict):
|
||||
if runtime is None:
|
||||
return False
|
||||
|
||||
# Handle case where command field might not exist (e.g., paused runtimes)
|
||||
command = runtime.get('command', '')
|
||||
if not command:
|
||||
# If no command field, check if this is a paused runtime
|
||||
# Paused runtimes should use the new conversation manager
|
||||
if runtime.get('status', '').lower() == 'paused':
|
||||
return False
|
||||
# Unknown state - default to False (use new manager)
|
||||
return False
|
||||
|
||||
# Ensure command is a string before checking substring
|
||||
if not isinstance(command, str):
|
||||
return False
|
||||
|
||||
return 'openhands.server' not in command
|
||||
return 'openhands.server' not in runtime['command']
|
||||
|
||||
@classmethod
|
||||
def get_instance(
|
||||
|
||||
@@ -417,35 +417,12 @@ async def refresh_tokens(
|
||||
x_session_api_key: Annotated[str | None, Header(alias='X-Session-API-Key')],
|
||||
) -> TokenResponse:
|
||||
"""Return the latest token for a given provider."""
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] /api/refresh-tokens called: provider={provider}, sid={sid}, '
|
||||
f'has_session_key={bool(x_session_api_key)}'
|
||||
)
|
||||
|
||||
user_id = _get_user_id(sid)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Got user_id: {user_id[:8]}...' if user_id else 'No user_id'
|
||||
)
|
||||
|
||||
session_api_key = await _get_session_api_key(user_id, sid)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Session key validation: '
|
||||
f'expected={session_api_key[:8] if session_api_key else None}..., '
|
||||
f'received={x_session_api_key[:8] if x_session_api_key else None}..., '
|
||||
f'match={session_api_key == x_session_api_key}'
|
||||
)
|
||||
|
||||
if session_api_key != x_session_api_key:
|
||||
logger.error(
|
||||
f'[TOKEN_DEBUG] Session key mismatch! Returning 403. '
|
||||
f'Expected: {session_api_key[:8] if session_api_key else "None"}..., '
|
||||
f'Got: {x_session_api_key[:8] if x_session_api_key else "None"}...'
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Forbidden')
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Session validated. Refreshing {provider} token for {sid}'
|
||||
)
|
||||
logger.info(f'Refreshing token for conversation {sid}')
|
||||
provider_handler = ProviderHandler(
|
||||
create_provider_tokens_object([provider]), external_auth_id=user_id
|
||||
)
|
||||
|
||||
@@ -149,6 +149,13 @@ async def on_batch_write(
|
||||
background_tasks: BackgroundTasks,
|
||||
x_session_api_key: Annotated[str | None, Header()],
|
||||
):
|
||||
logger.info(
|
||||
'batch_write_webhook',
|
||||
extra={
|
||||
'batch_ops': batch_ops,
|
||||
'x_session_api_key': x_session_api_key,
|
||||
},
|
||||
)
|
||||
"""Handle batched webhook requests with multiple file operations in background"""
|
||||
# Add the batch processing to background tasks
|
||||
background_tasks.add_task(
|
||||
|
||||
@@ -138,6 +138,7 @@ async def saas_search_repositories(
|
||||
per_page: int = 5,
|
||||
sort: str = 'stars',
|
||||
order: str = 'desc',
|
||||
selected_provider: ProviderType | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
@@ -155,6 +156,7 @@ async def saas_search_repositories(
|
||||
per_page=per_page,
|
||||
sort=sort,
|
||||
order=order,
|
||||
selected_provider=selected_provider,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -60,9 +60,14 @@ from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
RUNTIME_URL_PATTERN = os.getenv(
|
||||
'RUNTIME_URL_PATTERN', 'https://{runtime_id}.prod-runtime.all-hands.dev'
|
||||
)
|
||||
RUNTIME_ROUTING_MODE = os.getenv('RUNTIME_ROUTING_MODE', 'subdomain').lower()
|
||||
|
||||
# Pattern for base URL for the runtime
|
||||
RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + '/api/conversations/{conversation_id}'
|
||||
RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + (
|
||||
'/runtime/api/conversations/{conversation_id}'
|
||||
if RUNTIME_ROUTING_MODE == 'path'
|
||||
else '/api/conversations/{conversation_id}'
|
||||
)
|
||||
|
||||
# Time in seconds before a Redis entry is considered expired if not refreshed
|
||||
_REDIS_ENTRY_TIMEOUT_SECONDS = 300
|
||||
@@ -174,121 +179,37 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] SaasNestedConversationManager.maybe_start_agent_loop ENTRY: '
|
||||
f'sid={sid}, user_id={user_id}'
|
||||
)
|
||||
|
||||
# First we check redis to see if we are already starting - or the runtime will tell us the session is stopped
|
||||
redis = self._get_redis_client()
|
||||
key = self._get_redis_conversation_key(user_id, sid)
|
||||
starting = await redis.get(key)
|
||||
|
||||
logger.info(f'[TOKEN_DEBUG] Getting runtime for sid={sid}...')
|
||||
runtime = await self._get_runtime(sid)
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Runtime info for {sid}: '
|
||||
f'exists={runtime is not None}, '
|
||||
f'runtime_id={runtime.get("runtime_id") if runtime else None}, '
|
||||
f'status={runtime.get("status") if runtime else None}, '
|
||||
f'session_api_key_exists={bool(runtime.get("session_api_key")) if runtime else False}'
|
||||
)
|
||||
|
||||
# Get raw runtime status for branching decisions
|
||||
raw_runtime_status = (runtime.get('status') or '').lower() if runtime else ''
|
||||
|
||||
# Use _parse_status() only to compute the UI-facing ConversationStatus
|
||||
status = self._parse_status(runtime) if runtime else ConversationStatus.STOPPED
|
||||
|
||||
nested_url = None
|
||||
session_api_key = None
|
||||
status = ConversationStatus.STOPPED
|
||||
event_store = EventStore(sid, self.file_store, user_id)
|
||||
|
||||
if runtime:
|
||||
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
|
||||
session_api_key = runtime.get('session_api_key')
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Retrieved from runtime: '
|
||||
f'key_preview={session_api_key[:10] if session_api_key else "None"}..., '
|
||||
f'runtime_id={runtime.get("runtime_id")}, '
|
||||
f'raw_status={raw_runtime_status}, '
|
||||
f'parsed_status={status}'
|
||||
status_str = (runtime.get('status') or 'stopped').upper()
|
||||
if status_str in ConversationStatus:
|
||||
status = ConversationStatus[status_str]
|
||||
if status is ConversationStatus.STOPPED and starting:
|
||||
status = ConversationStatus.STARTING
|
||||
|
||||
if status is ConversationStatus.STOPPED:
|
||||
# Mark the agentloop as starting in redis
|
||||
await redis.set(key, 1, ex=_REDIS_ENTRY_TIMEOUT_SECONDS)
|
||||
|
||||
# Start the agent loop in the background
|
||||
asyncio.create_task(
|
||||
self._start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
)
|
||||
|
||||
# Determine if we need to start/resume the conversation
|
||||
# Key insight: We should only skip starting if:
|
||||
# 1. Runtime is running AND
|
||||
# 2. We're already starting (redis flag) OR conversation already exists
|
||||
|
||||
should_schedule_work = False
|
||||
is_resume = False
|
||||
|
||||
if raw_runtime_status == 'paused':
|
||||
# Always resume paused conversations
|
||||
should_schedule_work = True
|
||||
is_resume = True
|
||||
logger.info(f'[TOKEN_DEBUG] Will resume paused conversation {sid}')
|
||||
elif raw_runtime_status in ('stopped', ''):
|
||||
# Start new for stopped or non-existent runtimes
|
||||
should_schedule_work = True
|
||||
logger.info(f'[TOKEN_DEBUG] Will start new conversation {sid} (status={raw_runtime_status})')
|
||||
elif raw_runtime_status == 'running':
|
||||
# For running, only start if not already starting
|
||||
if starting:
|
||||
logger.info(f'[TOKEN_DEBUG] Already starting {sid} per Redis, returning STARTING')
|
||||
return AgentLoopInfo(
|
||||
conversation_id=sid,
|
||||
url=nested_url,
|
||||
session_api_key=session_api_key,
|
||||
event_store=event_store,
|
||||
status=ConversationStatus.STARTING,
|
||||
)
|
||||
else:
|
||||
# Runtime is running but we're not starting - this means conversation should exist
|
||||
# Return RUNNING status
|
||||
logger.info(f'[TOKEN_DEBUG] Runtime running and not starting, returning RUNNING')
|
||||
return AgentLoopInfo(
|
||||
conversation_id=sid,
|
||||
url=nested_url,
|
||||
session_api_key=session_api_key,
|
||||
event_store=event_store,
|
||||
status=ConversationStatus.RUNNING,
|
||||
)
|
||||
|
||||
if should_schedule_work:
|
||||
if not starting:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Scheduling {"resume" if is_resume else "start"} '
|
||||
f'for sid={sid} (raw_status={raw_runtime_status})'
|
||||
)
|
||||
await redis.set(key, 1, ex=_REDIS_ENTRY_TIMEOUT_SECONDS)
|
||||
|
||||
asyncio.create_task(
|
||||
self._start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json, is_resume
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Already starting {sid} according to Redis, not scheduling again'
|
||||
)
|
||||
|
||||
# Return STARTING when work is scheduled or in progress
|
||||
return AgentLoopInfo(
|
||||
conversation_id=sid,
|
||||
url=nested_url,
|
||||
session_api_key=session_api_key,
|
||||
event_store=event_store,
|
||||
status=ConversationStatus.STARTING,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Returning from maybe_start_agent_loop: '
|
||||
f'sid={sid}, status={status}, '
|
||||
f'has_url={bool(nested_url)}, has_api_key={bool(session_api_key)}'
|
||||
)
|
||||
|
||||
return AgentLoopInfo(
|
||||
conversation_id=sid,
|
||||
url=nested_url,
|
||||
@@ -298,29 +219,14 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
)
|
||||
|
||||
async def _start_agent_loop(
|
||||
self,
|
||||
sid,
|
||||
settings,
|
||||
user_id,
|
||||
initial_user_msg=None,
|
||||
replay_json=None,
|
||||
is_resume=False,
|
||||
self, sid, settings, user_id, initial_user_msg=None, replay_json=None
|
||||
):
|
||||
try:
|
||||
logger.info(f'starting_agent_loop:{sid}', extra={'session_id': sid})
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] SaaS _start_agent_loop: sid={sid}, is_resume={is_resume}'
|
||||
)
|
||||
|
||||
if is_resume:
|
||||
logger.info(f'[RESUME_DEBUG] Resuming existing runtime for sid={sid}')
|
||||
else:
|
||||
logger.info(f'[RESUME_DEBUG] Creating new runtime for sid={sid}')
|
||||
|
||||
await self.ensure_num_conversations_below_limit(sid, user_id)
|
||||
provider_handler = self._get_provider_handler(settings)
|
||||
runtime = await self._create_runtime(
|
||||
sid, user_id, settings, provider_handler, is_resume
|
||||
sid, user_id, settings, provider_handler
|
||||
)
|
||||
await runtime.connect()
|
||||
|
||||
@@ -332,51 +238,16 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
)
|
||||
|
||||
session_api_key = runtime.session.headers['X-Session-API-Key']
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Got session_api_key from runtime: '
|
||||
f'key_preview={session_api_key[:10] if session_api_key else "None"}...'
|
||||
)
|
||||
|
||||
# Check if we should skip conversation creation on resume
|
||||
if is_resume:
|
||||
# Get the existing runtime to check if we already have a session_api_key
|
||||
existing_runtime = await self._get_runtime(sid)
|
||||
if existing_runtime and existing_runtime.get('session_api_key'):
|
||||
logger.info(
|
||||
'[RESUME_DEBUG] Skipping conversation creation for resume '
|
||||
'(using existing session_api_key)'
|
||||
)
|
||||
# Use the EXISTING session_api_key for resume, not the new one
|
||||
existing_session_key = existing_runtime.get('session_api_key')
|
||||
# Just wait for the runtime to be ready
|
||||
async with httpx.AsyncClient(
|
||||
headers={'X-Session-API-Key': existing_session_key}
|
||||
) as client:
|
||||
await self._wait_for_conversation_ready(
|
||||
client, runtime.runtime_url, sid
|
||||
)
|
||||
else:
|
||||
# Resume but no existing session, create new conversation
|
||||
await self._start_conversation(
|
||||
sid,
|
||||
user_id,
|
||||
settings,
|
||||
initial_user_msg,
|
||||
replay_json,
|
||||
runtime.runtime_url,
|
||||
session_api_key,
|
||||
)
|
||||
else:
|
||||
# Not a resume, normal start
|
||||
await self._start_conversation(
|
||||
sid,
|
||||
user_id,
|
||||
settings,
|
||||
initial_user_msg,
|
||||
replay_json,
|
||||
runtime.runtime_url,
|
||||
session_api_key,
|
||||
)
|
||||
await self._start_conversation(
|
||||
sid,
|
||||
user_id,
|
||||
settings,
|
||||
initial_user_msg,
|
||||
replay_json,
|
||||
runtime.runtime_url,
|
||||
session_api_key,
|
||||
)
|
||||
finally:
|
||||
# remove the starting entry from redis
|
||||
redis = self._get_redis_client()
|
||||
@@ -394,11 +265,6 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
session_api_key: str,
|
||||
):
|
||||
logger.info('starting_nested_conversation', extra={'sid': sid})
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] _start_conversation with session_api_key: '
|
||||
f'key_preview={session_api_key[:10] if session_api_key else "None"}..., '
|
||||
f'api_url={api_url[:50] if api_url else "None"}...'
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
headers={
|
||||
'X-Session-API-Key': session_api_key,
|
||||
@@ -557,35 +423,16 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
):
|
||||
"""Wait for the conversation to be ready by checking the events endpoint."""
|
||||
# TODO: Find out why /api/conversations/{sid} returns RUNNING when events are not available
|
||||
logger.info(
|
||||
f'[WEBSOCKET_DEBUG] Starting _wait_for_conversation_ready for sid={sid}, '
|
||||
f'will check events endpoint up to 5 times'
|
||||
)
|
||||
for attempt in range(5):
|
||||
for _ in range(5):
|
||||
try:
|
||||
logger.info('checking_events_endpoint_running', extra={'sid': sid})
|
||||
logger.info(
|
||||
f'[WEBSOCKET_DEBUG] Attempt {attempt+1}/5: Checking {api_url}/api/conversations/{sid}/events'
|
||||
)
|
||||
response = await client.get(f'{api_url}/api/conversations/{sid}/events')
|
||||
if response.is_success:
|
||||
logger.info('events_endpoint_is_running', extra={'sid': sid})
|
||||
logger.info(
|
||||
f'[WEBSOCKET_DEBUG] Events endpoint ready after {attempt+1} attempts. '
|
||||
f'Frontend should now be able to connect via websocket.'
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.warning('events_endpoint_not_ready', extra={'sid': sid})
|
||||
logger.warning(
|
||||
f'[WEBSOCKET_DEBUG] Events endpoint not ready (attempt {attempt+1}/5): {e}'
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
else:
|
||||
logger.error(
|
||||
f'[WEBSOCKET_DEBUG] CRITICAL: Events endpoint never became ready after 5 attempts! '
|
||||
f'Frontend will not receive events for sid={sid}'
|
||||
)
|
||||
|
||||
async def send_to_event_stream(self, connection_id: str, data: dict):
|
||||
# Not supported - clients should connect directly to the nested server!
|
||||
@@ -620,17 +467,10 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
|
||||
async def close_session(self, sid: str):
|
||||
logger.info('close_session', extra={'sid': sid})
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] close_session called for {sid}, about to pause runtime'
|
||||
)
|
||||
runtime = await self._get_runtime(sid)
|
||||
if runtime is None:
|
||||
logger.info('no_session_to_close', extra={'sid': sid})
|
||||
logger.info(f'[TOKEN_DEBUG] No runtime found to close for {sid}')
|
||||
return
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Pausing runtime {runtime.get("runtime_id")} for session {sid}'
|
||||
)
|
||||
async with self._httpx_client() as client:
|
||||
response = await client.post(
|
||||
f'{self.remote_runtime_api_url}/pause',
|
||||
@@ -856,15 +696,6 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
provider_tokens = None
|
||||
if isinstance(settings, ConversationInitData):
|
||||
provider_tokens = settings.git_provider_tokens
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Getting provider handler: '
|
||||
f'has_settings={settings is not None}, '
|
||||
f'is_ConversationInitData={isinstance(settings, ConversationInitData)}, '
|
||||
f'has_provider_tokens={bool(provider_tokens)}, '
|
||||
f'token_count={len(provider_tokens) if provider_tokens else 0}'
|
||||
)
|
||||
|
||||
provider_handler = ProviderHandler(
|
||||
provider_tokens=provider_tokens
|
||||
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({}))
|
||||
@@ -877,40 +708,7 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
user_id: str,
|
||||
settings: Settings,
|
||||
provider_handler: ProviderHandler,
|
||||
is_resume: bool = False,
|
||||
):
|
||||
# Check if we have an existing runtime to understand the context
|
||||
logger.info(
|
||||
f'[RESUME_DEBUG] _create_runtime called for sid={sid}, is_resume={is_resume}'
|
||||
)
|
||||
existing_runtime = await self._get_runtime(sid)
|
||||
|
||||
# Determine if we should attach to existing runtime
|
||||
attach_to_existing = False
|
||||
if existing_runtime:
|
||||
raw_status = (existing_runtime.get('status') or '').lower()
|
||||
logger.info(
|
||||
f'[RESUME_DEBUG] Found existing runtime: '
|
||||
f'runtime_id={existing_runtime.get("runtime_id")}, '
|
||||
f'status={raw_status}, '
|
||||
f'has_api_key={bool(existing_runtime.get("session_api_key"))}'
|
||||
)
|
||||
|
||||
# Attach to existing runtime if it's paused or running
|
||||
if raw_status in ('paused', 'running'):
|
||||
attach_to_existing = True
|
||||
logger.info(
|
||||
f'[RESUME_DEBUG] Will attach to existing {raw_status} runtime'
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f'[RESUME_DEBUG] Will create new runtime (existing is {raw_status})'
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'[RESUME_DEBUG] No existing runtime found, creating fresh runtime'
|
||||
)
|
||||
|
||||
llm_registry, conversation_stats, config = (
|
||||
create_registry_and_conversation_stats(self.config, sid, user_id, settings)
|
||||
)
|
||||
@@ -971,29 +769,6 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
if self._runtime_container_image:
|
||||
config.sandbox.runtime_container_image = self._runtime_container_image
|
||||
|
||||
# Log the attach_to_existing decision
|
||||
logger.info(
|
||||
f'[ATTACH_DEBUG] Making attach_to_existing decision: '
|
||||
f'sid={sid}, attach_to_existing={attach_to_existing}, '
|
||||
f'reasoning={"attach to paused/running" if attach_to_existing else "create new"}'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Creating RemoteRuntime: '
|
||||
f'sid={sid}, attach_to_existing={attach_to_existing}, '
|
||||
f'user_id={user_id}, '
|
||||
f'has_provider_tokens={bool(provider_handler and provider_handler.provider_tokens)}'
|
||||
)
|
||||
|
||||
# Log the state of tokens before runtime creation
|
||||
if provider_handler and provider_handler.provider_tokens:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Provider tokens before runtime creation: '
|
||||
f'{list(provider_handler.provider_tokens.keys())}'
|
||||
)
|
||||
else:
|
||||
logger.info('[TOKEN_DEBUG] No provider tokens before runtime creation')
|
||||
|
||||
runtime = RemoteRuntime(
|
||||
config=config,
|
||||
event_stream=None, # type: ignore[arg-type]
|
||||
@@ -1001,7 +776,7 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
plugins=agent.sandbox_plugins,
|
||||
# env_vars=env_vars,
|
||||
# status_callback: Callable[..., None] | None = None,
|
||||
attach_to_existing=attach_to_existing,
|
||||
attach_to_existing=False,
|
||||
headless_mode=False,
|
||||
user_id=user_id,
|
||||
# git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
@@ -1009,10 +784,6 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
llm_registry=llm_registry,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] RemoteRuntime created: runtime_id={runtime.runtime_id if hasattr(runtime, "runtime_id") else "N/A"}'
|
||||
)
|
||||
|
||||
# TODO: This is a hack. The setup_initial_env method directly calls the methods on the action
|
||||
# execution server, even though there are not any variables to set. In the nested env, there
|
||||
# is currently no direct access to the action execution server, so we should either add a
|
||||
@@ -1060,27 +831,10 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
|
||||
async def _get_runtime(self, sid: str) -> dict | None:
|
||||
async with self._httpx_client() as client:
|
||||
url = f'{self.remote_runtime_api_url}/sessions/{sid}'
|
||||
logger.info(f'[TOKEN_DEBUG] Fetching runtime from: {url}')
|
||||
|
||||
response = await client.get(url)
|
||||
|
||||
response = await client.get(f'{self.remote_runtime_api_url}/sessions/{sid}')
|
||||
if not response.is_success:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Runtime fetch failed: '
|
||||
f'status_code={response.status_code}, '
|
||||
f'sid={sid}'
|
||||
)
|
||||
return None
|
||||
|
||||
response_json = response.json()
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Runtime fetched successfully: '
|
||||
f'sid={sid}, '
|
||||
f'runtime_id={response_json.get("runtime_id")}, '
|
||||
f'status={response_json.get("status")}, '
|
||||
f'has_api_key={bool(response_json.get("session_api_key"))}'
|
||||
)
|
||||
|
||||
# Hack: This endpoint doesn't return the session_id
|
||||
response_json['session_id'] = sid
|
||||
@@ -1090,26 +844,12 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
def _parse_status(self, runtime: dict):
|
||||
# status is one of running, stoppped, paused, error, starting
|
||||
status = (runtime.get('status') or '').upper()
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] _parse_status: input_status="{runtime.get("status")}", '
|
||||
f'uppercase_status="{status}", '
|
||||
f'is_paused={status == "PAUSED"}, '
|
||||
f'is_stopped={status == "STOPPED"}'
|
||||
)
|
||||
|
||||
if status == 'PAUSED':
|
||||
logger.info('[TOKEN_DEBUG] Mapping PAUSED -> ConversationStatus.STOPPED')
|
||||
return ConversationStatus.STOPPED
|
||||
elif status == 'STOPPED':
|
||||
logger.info('[TOKEN_DEBUG] Mapping STOPPED -> ConversationStatus.ARCHIVED')
|
||||
return ConversationStatus.ARCHIVED
|
||||
if status in ConversationStatus:
|
||||
logger.info(f'[TOKEN_DEBUG] Direct mapping to ConversationStatus.{status}')
|
||||
return ConversationStatus[status]
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Unknown status "{status}", defaulting to ConversationStatus.STOPPED'
|
||||
)
|
||||
return ConversationStatus.STOPPED
|
||||
|
||||
def _get_nested_url_for_runtime(self, runtime_id: str, conversation_id: str):
|
||||
|
||||
@@ -37,14 +37,6 @@ class ApiKeyStore:
|
||||
"""
|
||||
api_key = self.generate_api_key()
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Creating API key: '
|
||||
f'user_id={user_id}, '
|
||||
f'name={name}, '
|
||||
f'expires_at={expires_at}, '
|
||||
f'key_preview={api_key[:10] if api_key else "None"}...'
|
||||
)
|
||||
|
||||
with self.session_maker() as session:
|
||||
key_record = ApiKey(
|
||||
key=api_key, user_id=user_id, name=name, expires_at=expires_at
|
||||
@@ -52,43 +44,21 @@ class ApiKeyStore:
|
||||
session.add(key_record)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] API key created successfully: '
|
||||
f'key_id={key_record.id}, '
|
||||
f'user_id={user_id}, '
|
||||
f'name={name}'
|
||||
)
|
||||
|
||||
return api_key
|
||||
|
||||
def validate_api_key(self, api_key: str) -> str | None:
|
||||
"""Validate an API key and return the associated user_id if valid."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Validating API key: '
|
||||
f'key_preview={api_key[:10] if api_key else "None"}...'
|
||||
)
|
||||
|
||||
with self.session_maker() as session:
|
||||
key_record = session.query(ApiKey).filter(ApiKey.key == api_key).first()
|
||||
|
||||
if not key_record:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] API key not found in database: '
|
||||
f'key_preview={api_key[:10] if api_key else "None"}...'
|
||||
)
|
||||
return None
|
||||
|
||||
# Check if the key has expired
|
||||
if key_record.expires_at and key_record.expires_at < now:
|
||||
logger.info(f'API key has expired: {key_record.id}')
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] API key expired: '
|
||||
f'key_id={key_record.id}, '
|
||||
f'expires_at={key_record.expires_at}, '
|
||||
f'now={now}'
|
||||
)
|
||||
return None
|
||||
|
||||
# Update last_used_at timestamp
|
||||
@@ -99,13 +69,6 @@ class ApiKeyStore:
|
||||
)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] API key validated successfully: '
|
||||
f'key_id={key_record.id}, '
|
||||
f'user_id={key_record.user_id}, '
|
||||
f'name={key_record.name}'
|
||||
)
|
||||
|
||||
return key_record.user_id
|
||||
|
||||
def delete_api_key(self, api_key: str) -> bool:
|
||||
|
||||
@@ -175,17 +175,17 @@ class TestIsLegacyRuntime:
|
||||
assert result is True
|
||||
|
||||
def test_is_legacy_runtime_empty_command(self, legacy_manager):
|
||||
"""Test with empty command - should use new manager."""
|
||||
"""Test with empty command."""
|
||||
runtime = {'command': ''}
|
||||
result = legacy_manager.is_legacy_runtime(runtime)
|
||||
assert result is False # Empty command means use new manager
|
||||
assert result is True
|
||||
|
||||
def test_is_legacy_runtime_missing_command_key(self, legacy_manager):
|
||||
"""Test with runtime missing command key - should use new manager."""
|
||||
"""Test with runtime missing command key."""
|
||||
runtime = {'other_key': 'value'}
|
||||
# Should not raise KeyError, returns False (use new manager)
|
||||
result = legacy_manager.is_legacy_runtime(runtime)
|
||||
assert result is False
|
||||
# This should raise a KeyError
|
||||
with pytest.raises(KeyError):
|
||||
legacy_manager.is_legacy_runtime(runtime)
|
||||
|
||||
|
||||
class TestShouldStartInLegacyMode:
|
||||
|
||||
@@ -17,8 +17,8 @@ import type { Message } from "#/message";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { ChatInterface } from "#/components/features/chat/chat-interface";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
@@ -26,8 +26,8 @@ import { OpenHandsAction } from "#/types/core/actions";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
vi.mock("#/hooks/use-optimistic-user-message");
|
||||
vi.mock("#/hooks/use-ws-error-message");
|
||||
vi.mock("#/stores/error-message-store");
|
||||
vi.mock("#/stores/optimistic-user-message-store");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-get-trajectory");
|
||||
vi.mock("#/hooks/mutation/use-upload-files");
|
||||
@@ -61,7 +61,6 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
// Helper function to render with Router context
|
||||
const renderChatInterfaceWithRouter = () =>
|
||||
renderWithProviders(
|
||||
@@ -109,13 +108,14 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
parsedEvents: [],
|
||||
});
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
useOptimisticUserMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => null),
|
||||
});
|
||||
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
getErrorMessage: vi.fn(() => null),
|
||||
(
|
||||
useErrorMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setErrorMessage: vi.fn(),
|
||||
removeErrorMessage: vi.fn(),
|
||||
});
|
||||
@@ -203,7 +203,7 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
|
||||
test("should hide chat suggestions when there is an optimistic user message", () => {
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
useOptimisticUserMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => "Optimistic message"),
|
||||
@@ -246,13 +246,14 @@ describe("ChatInterface - Empty state", () => {
|
||||
parsedEvents: [],
|
||||
});
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
useOptimisticUserMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => null),
|
||||
});
|
||||
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
getErrorMessage: vi.fn(() => null),
|
||||
(
|
||||
useErrorMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setErrorMessage: vi.fn(),
|
||||
removeErrorMessage: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -157,8 +157,52 @@ describe("MicroagentManagement", () => {
|
||||
owner_type: "organization",
|
||||
pushed_at: "2021-10-06T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
full_name: "user/gitlab-repo/openhands-config",
|
||||
git_provider: "gitlab",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-07T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
full_name: "org/gitlab-org-repo/openhands-config",
|
||||
git_provider: "gitlab",
|
||||
is_public: true,
|
||||
owner_type: "organization",
|
||||
pushed_at: "2021-10-08T12:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to filter repositories with OpenHands suffixes
|
||||
const getRepositoriesWithOpenHandsSuffix = (
|
||||
repositories: GitRepository[],
|
||||
) => {
|
||||
return repositories.filter(
|
||||
(repo) =>
|
||||
repo.full_name.endsWith("/.openhands") ||
|
||||
repo.full_name.endsWith("/openhands-config"),
|
||||
);
|
||||
};
|
||||
|
||||
// Helper functions for mocking search repositories
|
||||
const mockSearchRepositoriesWithData = (data: GitRepository[]) => {
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
};
|
||||
|
||||
const mockSearchRepositoriesEmpty = () => {
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
};
|
||||
|
||||
const mockMicroagents: RepositoryMicroagent[] = [
|
||||
{
|
||||
name: "test-microagent-1",
|
||||
@@ -265,11 +309,11 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
// Mock the search repositories hook to return repositories with OpenHands suffixes
|
||||
const mockSearchResults =
|
||||
getRepositoriesWithOpenHandsSuffix(mockRepositories);
|
||||
|
||||
mockSearchRepositoriesWithData(mockSearchResults);
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(GitService, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
@@ -594,6 +638,9 @@ describe("MicroagentManagement", () => {
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock empty search results
|
||||
mockSearchRepositoriesEmpty();
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
@@ -782,6 +829,10 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
it("should handle empty search results", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock empty search results for this test
|
||||
mockSearchRepositoriesEmpty();
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
|
||||
175
frontend/package-lock.json
generated
175
frontend/package-lock.json
generated
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.9.1",
|
||||
"@react-router/serve": "^7.9.1",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@react-router/serve": "^7.9.3",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
@@ -21,7 +21,7 @@
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.12.2",
|
||||
@@ -30,23 +30,23 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "^9.0.10",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.19",
|
||||
"framer-motion": "^12.23.22",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.30",
|
||||
"isbot": "^5.1.31",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"posthog-js": "^1.268.1",
|
||||
"posthog-js": "^1.268.8",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^15.7.2",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.1",
|
||||
"react-router": "^7.9.3",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -65,15 +65,15 @@
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@react-router/dev": "^7.9.1",
|
||||
"@tailwindcss/typography": "^0.5.18",
|
||||
"@react-router/dev": "^7.9.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/eslint-plugin-query": "^5.90.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react": "^19.1.15",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
@@ -96,7 +96,7 @@
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.0.0",
|
||||
"lint-staged": "^16.2.0",
|
||||
"lint-staged": "^16.2.3",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.5.0",
|
||||
@@ -3511,9 +3511,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.1.0.tgz",
|
||||
"integrity": "sha512-igElrcnRPJh2nWYACschjH4OwGwzSa6xVFzRDVzpnjirUivdJ8nv4hE+H31nvwE56MFhvvglfHuotnWLMcRW7w=="
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.2.tgz",
|
||||
"integrity": "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg=="
|
||||
},
|
||||
"node_modules/@react-aria/breadcrumbs": {
|
||||
"version": "3.5.28",
|
||||
@@ -4201,9 +4201,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev": {
|
||||
"version": "7.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.9.1.tgz",
|
||||
"integrity": "sha512-fW/qubsdHq1nsufHPLpXa6hiNvXXV9JBtWqRlJ02OOhFeaWERZw4rGoHjG1DCg8/QTTadgbzplmP97ZnzWPkcA==",
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.9.3.tgz",
|
||||
"integrity": "sha512-oPaO+OpvCo/rNTJrRipHSp31/K4It19PE5A24x21FlYlemPTe3fbGX/kyC2+8au/abXbvzNHfRbuIBD/rfojmA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.27.7",
|
||||
@@ -4214,7 +4214,8 @@
|
||||
"@babel/traverse": "^7.27.7",
|
||||
"@babel/types": "^7.27.7",
|
||||
"@npmcli/package-json": "^4.0.1",
|
||||
"@react-router/node": "7.9.1",
|
||||
"@react-router/node": "7.9.3",
|
||||
"@remix-run/node-fetch-server": "^0.9.0",
|
||||
"arg": "^5.0.1",
|
||||
"babel-dead-code-elimination": "^1.0.6",
|
||||
"chokidar": "^4.0.0",
|
||||
@@ -4229,7 +4230,6 @@
|
||||
"prettier": "^3.6.2",
|
||||
"react-refresh": "^0.14.0",
|
||||
"semver": "^7.3.7",
|
||||
"set-cookie-parser": "^2.6.0",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"valibot": "^0.41.0",
|
||||
"vite-node": "^3.2.2"
|
||||
@@ -4241,9 +4241,9 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-router/serve": "^7.9.1",
|
||||
"@react-router/serve": "^7.9.3",
|
||||
"@vitejs/plugin-rsc": "*",
|
||||
"react-router": "^7.9.1",
|
||||
"react-router": "^7.9.3",
|
||||
"typescript": "^5.1.0",
|
||||
"vite": "^5.1.0 || ^6.0.0 || ^7.0.0",
|
||||
"wrangler": "^3.28.2 || ^4.0.0"
|
||||
@@ -4277,9 +4277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/node": {
|
||||
"version": "7.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.9.1.tgz",
|
||||
"integrity": "sha512-XfyVLM+sDUDB1frGNr7iqaKNglrPwBiUp8+sFdL4///bngy49pUb2RuEtn2J2Cy5yjL+IlKbjJFrsmfimLBmeg==",
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.9.3.tgz",
|
||||
"integrity": "sha512-+OvWxPPUgouOshw85QlG0J6yFJM0GMCCpXqPj38IcveeFLlP7ppOAEkOi7RBFrDvg7vSUtCEBDnsbuDCvxUPJg==",
|
||||
"dependencies": {
|
||||
"@mjackson/node-fetch-server": "^0.2.0"
|
||||
},
|
||||
@@ -4287,7 +4287,7 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-router": "7.9.1",
|
||||
"react-router": "7.9.3",
|
||||
"typescript": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -4297,12 +4297,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/serve": {
|
||||
"version": "7.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.9.1.tgz",
|
||||
"integrity": "sha512-yVBSb5KsNCdkSoOk186/M5GJtcIvKE32Ax9LhXySVpM+suCSjucI+p2TXDOJIYsBqr2aKcBl/bNBm5sIJxG/HA==",
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.9.3.tgz",
|
||||
"integrity": "sha512-wtiDLo4sY3ouADXPm1xa4eg79zRXP517E0QcuBKPfoKh/40IcANTqN11VeEKNA9QgNxLeCm4CSY3dPbqePuwkA==",
|
||||
"dependencies": {
|
||||
"@react-router/express": "7.9.1",
|
||||
"@react-router/node": "7.9.1",
|
||||
"@mjackson/node-fetch-server": "^0.2.0",
|
||||
"@react-router/express": "7.9.3",
|
||||
"@react-router/node": "7.9.3",
|
||||
"compression": "^1.7.4",
|
||||
"express": "^4.19.2",
|
||||
"get-port": "5.1.1",
|
||||
@@ -4316,22 +4317,22 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-router": "7.9.1"
|
||||
"react-router": "7.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/serve/node_modules/@react-router/express": {
|
||||
"version": "7.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.9.1.tgz",
|
||||
"integrity": "sha512-d1sfsD3AJXZj+C5k3jAmxAD3vJXGfoh3lNmtSwxp0NdZFHI54zPC5S9o80cy3P8p6Gc7XzSEQJYk9k7fAM/AIw==",
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.9.3.tgz",
|
||||
"integrity": "sha512-XNVj/8AfecE1n61bXD41LqpXAixyWBpmBWkrzVA2iG+SrQOb+J6TjqZYEmZmoqJHuHmkOjt6/Iz1f81p93peGQ==",
|
||||
"dependencies": {
|
||||
"@react-router/node": "7.9.1"
|
||||
"@react-router/node": "7.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.17.1 || ^5",
|
||||
"react-router": "7.9.1",
|
||||
"react-router": "7.9.3",
|
||||
"typescript": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -4975,10 +4976,16 @@
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/node-fetch-server": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.9.0.tgz",
|
||||
"integrity": "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.35",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
|
||||
"integrity": "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg=="
|
||||
"version": "1.0.0-beta.38",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
|
||||
"integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw=="
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
@@ -5921,9 +5928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.18.tgz",
|
||||
"integrity": "sha512-dDIgwZOlf+tVkZ7A029VvQ1+ngKATENDjMEx2N35s2yPjfTS05RWSM8ilhEWSa5DMJ6ci2Ha9WNZEd2GQjrdQg==",
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
@@ -6255,9 +6262,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
||||
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
|
||||
"version": "19.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.15.tgz",
|
||||
"integrity": "sha512-+kLxJpaJzXybyDyFXYADyP1cznTO8HSuBpenGlnKOAkH4hyNINiywvXS/tGJhsrGGP/gM185RA3xpjY0Yg4erA==",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -6728,14 +6735,14 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz",
|
||||
"integrity": "sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==",
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz",
|
||||
"integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.4",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.35",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.38",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
@@ -10029,11 +10036,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.19",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.19.tgz",
|
||||
"integrity": "sha512-AaWAohgTs2+wUoDdpJaaqMgV6vkm1uzzDlZUItem45linLrFiFqi4iw7bryhcVqu4loaaSLtSjAojfCAB3qczw==",
|
||||
"version": "12.23.22",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz",
|
||||
"integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.19",
|
||||
"motion-dom": "^12.23.21",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
@@ -11385,10 +11392,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isbot": {
|
||||
"version": "5.1.30",
|
||||
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.30.tgz",
|
||||
"integrity": "sha512-3wVJEonAns1OETX83uWsk5IAne2S5zfDcntD2hbtU23LelSqNXzXs9zKjMPOLMzroCgIjCfjYAEHrd2D6FOkiA==",
|
||||
"license": "Unlicense",
|
||||
"version": "5.1.31",
|
||||
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.31.tgz",
|
||||
"integrity": "sha512-DPgQshehErHAqSCKDb3rNW03pa2wS/v5evvUqtxt6TTnHRqAG8FdzcSSJs9656pK6Y+NT7K9R4acEYXLHYfpUQ==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -11924,18 +11930,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lint-staged": {
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.0.tgz",
|
||||
"integrity": "sha512-spdYSOCQ2MdZ9CM1/bu/kDmaYGsrpNOeu1InFFV8uhv14x6YIubGxbCpSmGILFoxkiheNQPDXSg5Sbb5ZuVnug==",
|
||||
"version": "16.2.3",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.3.tgz",
|
||||
"integrity": "sha512-1OnJEESB9zZqsp61XHH2fvpS1es3hRCxMplF/AJUDa8Ho8VrscYDIuxGrj3m8KPXbcWZ8fT9XTMUhEQmOVKpKw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"commander": "14.0.1",
|
||||
"listr2": "9.0.4",
|
||||
"micromatch": "4.0.8",
|
||||
"nano-spawn": "1.0.3",
|
||||
"pidtree": "0.6.0",
|
||||
"string-argv": "0.3.2",
|
||||
"yaml": "2.8.1"
|
||||
"commander": "^14.0.1",
|
||||
"listr2": "^9.0.4",
|
||||
"micromatch": "^4.0.8",
|
||||
"nano-spawn": "^1.0.3",
|
||||
"pidtree": "^0.6.0",
|
||||
"string-argv": "^0.3.2",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"bin": {
|
||||
"lint-staged": "bin/lint-staged.js"
|
||||
@@ -13412,9 +13418,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.19",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.19.tgz",
|
||||
"integrity": "sha512-ivUCJ0zVZt7S++D8+ONeefkJj/8JlpCRYzGegLdXr8Z9aWg64KyljdaCGVa54Vv0K8hNE7vRQSaQve7V5l3rMw==",
|
||||
"version": "12.23.21",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz",
|
||||
"integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
@@ -14258,11 +14264,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.268.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.268.1.tgz",
|
||||
"integrity": "sha512-vkV8vFHUWtPsFeHZCCszGdnLxKJn93UVw7a7SZGTJyyQ3JBC1Sydy4DvolnDt2IhqIUZCs9ljwqaUXcITqLoEg==",
|
||||
"version": "1.268.8",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.268.8.tgz",
|
||||
"integrity": "sha512-BJiKK4MlUvs7ybnQcy1KkwAz+SZkE/wRLotetIoank5kbqZs8FLbeyozFvmmgx4aoMmaVymYBSmYphYjYQeidw==",
|
||||
"dependencies": {
|
||||
"@posthog/core": "1.1.0",
|
||||
"@posthog/core": "1.2.2",
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.19.3",
|
||||
@@ -14625,16 +14631,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "15.7.3",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.3.tgz",
|
||||
"integrity": "sha512-AANws4tOE+QSq/IeMF/ncoHlMNZaVLxpa5uUGW1wjike68elVYr0018L9xYoqBr1OFO7G7boDPrbn0HpMCJxTw==",
|
||||
"license": "MIT",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz",
|
||||
"integrity": "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.4.1",
|
||||
"i18next": ">= 25.5.2",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
@@ -14709,9 +14714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.1.tgz",
|
||||
"integrity": "sha512-pfAByjcTpX55mqSDGwGnY9vDCpxqBLASg0BMNAuMmpSGESo/TaOUG6BllhAtAkCGx8Rnohik/XtaqiYUJtgW2g==",
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
|
||||
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -11,8 +11,8 @@
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.9.1",
|
||||
"@react-router/serve": "^7.9.1",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@react-router/serve": "^7.9.3",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.12.2",
|
||||
@@ -29,23 +29,23 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "^9.0.10",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.19",
|
||||
"framer-motion": "^12.23.22",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.30",
|
||||
"isbot": "^5.1.31",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"posthog-js": "^1.268.1",
|
||||
"posthog-js": "^1.268.8",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^15.7.2",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.1",
|
||||
"react-router": "^7.9.3",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -96,15 +96,15 @@
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@react-router/dev": "^7.9.1",
|
||||
"@tailwindcss/typography": "^0.5.18",
|
||||
"@react-router/dev": "^7.9.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/eslint-plugin-query": "^5.90.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react": "^19.1.15",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
@@ -127,7 +127,7 @@
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.0.0",
|
||||
"lint-staged": "^16.2.0",
|
||||
"lint-staged": "^16.2.3",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.5.0",
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
"packageManager": "npm@10.5.0",
|
||||
"volta": {
|
||||
"node": "18.20.1"
|
||||
"node": "22.0.0"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
|
||||
@@ -23,7 +23,7 @@ class GitService {
|
||||
*/
|
||||
static async searchGitRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
per_page = 100,
|
||||
selected_provider?: Provider,
|
||||
): Promise<GitRepository[]> {
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
|
||||
@@ -22,8 +22,8 @@ import { useAgentStore } from "#/stores/agent-store";
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { ErrorMessageBanner } from "./error-message-banner";
|
||||
import {
|
||||
hasUserEvent,
|
||||
@@ -46,10 +46,10 @@ function getEntryPoint(
|
||||
|
||||
export function ChatInterface() {
|
||||
const { setMessageToSend } = useConversationStore();
|
||||
const { getErrorMessage } = useWSErrorMessage();
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
const { send, isLoadingMessages, parsedEvents } = useWsClient();
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessage();
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
@@ -73,7 +73,6 @@ export function ChatInterface() {
|
||||
const { mutateAsync: uploadFiles } = useUploadFiles();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
const errorMessage = getErrorMessage();
|
||||
|
||||
const events = parsedEvents.filter(shouldRenderEvent);
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ChatMessage({
|
||||
"rounded-xl relative w-fit max-w-full last:mb-4",
|
||||
"flex flex-col gap-2",
|
||||
type === "user" && " p-4 bg-tertiary self-end",
|
||||
type === "agent" && "mt-6 max-w-full bg-transparent",
|
||||
type === "agent" && "mt-6 w-full max-w-full bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -6,7 +6,12 @@ export interface ChatStopButtonProps {
|
||||
|
||||
export function ChatStopButton({ handleStop }: ChatStopButtonProps) {
|
||||
return (
|
||||
<button type="button" onClick={handleStop} data-testid="stop-button">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
data-testid="stop-button"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<PauseIcon className="block max-w-none w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { isFileImage } from "#/utils/is-file-image";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
@@ -33,25 +32,6 @@ export function InteractiveChatBox({
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
// Track if agent has reached AWAITING_USER_INPUT state for first time
|
||||
const [hasSeenAwaitingUserInput, setHasSeenAwaitingUserInput] = useState(false);
|
||||
|
||||
// Reset the flag when conversation is STARTING (new or restart)
|
||||
useEffect(() => {
|
||||
if (conversation?.status === "STARTING") {
|
||||
console.log("[CHAT_INPUT_DEBUG] Conversation STARTING - resetting hasSeenAwaitingUserInput to false");
|
||||
setHasSeenAwaitingUserInput(false);
|
||||
}
|
||||
}, [conversation?.status]);
|
||||
|
||||
// Set flag when we see AWAITING_USER_INPUT for the first time
|
||||
useEffect(() => {
|
||||
if (curAgentState === AgentState.AWAITING_USER_INPUT && !hasSeenAwaitingUserInput) {
|
||||
console.log("[CHAT_INPUT_DEBUG] Agent reached AWAITING_USER_INPUT - enabling input");
|
||||
setHasSeenAwaitingUserInput(true);
|
||||
}
|
||||
}, [curAgentState, hasSeenAwaitingUserInput]);
|
||||
|
||||
// Helper function to validate and filter files
|
||||
const validateAndFilterFiles = (selectedFiles: File[]) => {
|
||||
const validation = validateFiles(selectedFiles, [...images, ...files]);
|
||||
@@ -157,18 +137,9 @@ export function InteractiveChatBox({
|
||||
};
|
||||
|
||||
const isDisabled =
|
||||
!hasSeenAwaitingUserInput || // Block until first AWAITING_USER_INPUT
|
||||
curAgentState === AgentState.LOADING ||
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
|
||||
|
||||
// Debug logging for disable state
|
||||
console.log("[CHAT_INPUT_DEBUG] Input disabled state:", {
|
||||
isDisabled,
|
||||
hasSeenAwaitingUserInput,
|
||||
curAgentState,
|
||||
conversationStatus: conversation?.status
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-testid="interactive-chat-box">
|
||||
<CustomChatInput
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "#/types/core/guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { LaunchMicroagentModal } from "./microagent/launch-microagent-modal";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
@@ -48,7 +48,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
isPending,
|
||||
unsubscribeFromConversation,
|
||||
} = useCreateConversationAndSubscribeMultiple();
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useUserConversation(conversationId);
|
||||
|
||||
|
||||
@@ -34,8 +34,7 @@ export function ServerStatus({
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
|
||||
const isStopStatus =
|
||||
curAgentState === AgentState.STOPPED || conversationStatus === "STOPPED";
|
||||
const isStopStatus = conversationStatus === "STOPPED";
|
||||
|
||||
// Get the appropriate color based on agent status
|
||||
const getStatusColor = (): string => {
|
||||
|
||||
@@ -47,7 +47,6 @@ export function BranchDropdownMenu({
|
||||
key={branch.name}
|
||||
item={branch}
|
||||
index={index}
|
||||
isHighlighted={currentHighlightedIndex === index}
|
||||
isSelected={currentSelectedItem?.name === branch.name}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={(branchItem) => branchItem.name}
|
||||
|
||||
@@ -134,7 +134,6 @@ export function GitProviderDropdown({
|
||||
key={item}
|
||||
item={item}
|
||||
index={index}
|
||||
isHighlighted={index === currentHighlightedIndex}
|
||||
isSelected={item === currentSelectedItem}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={formatProviderName}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { GenericDropdownMenu } from "../shared/generic-dropdown-menu";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import RepoIcon from "#/icons/repo.svg?react";
|
||||
import { useHomeStore } from "#/stores/home-store";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
export interface GitRepoDropdownProps {
|
||||
provider: Provider;
|
||||
@@ -45,6 +47,7 @@ export function GitRepoDropdown({
|
||||
}: GitRepoDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const { recentRepositories: storedRecentRepositories } = useHomeStore();
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [localSelectedItem, setLocalSelectedItem] =
|
||||
useState<GitRepository | null>(null);
|
||||
@@ -88,37 +91,78 @@ export function GitRepoDropdown({
|
||||
repositoryName,
|
||||
);
|
||||
|
||||
// Filter repositories based on input value
|
||||
const filteredRepositories = useMemo(() => {
|
||||
// If we have URL search results, show them directly (no filtering needed)
|
||||
if (urlSearchResults.length > 0) {
|
||||
return repositories;
|
||||
}
|
||||
// Get recent repositories filtered by provider and input keyword
|
||||
const recentRepositories = useMemo(() => {
|
||||
const allRecentRepos = storedRecentRepositories;
|
||||
const providerFilteredRepos = allRecentRepos.filter(
|
||||
(repo) => repo.git_provider === provider,
|
||||
);
|
||||
|
||||
// If we have a selected repository and the input matches it exactly, show all repositories
|
||||
if (selectedRepository && inputValue === selectedRepository.full_name) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// If no input value, show all repositories
|
||||
// If no input value, return all recent repos for this provider
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
return repositories;
|
||||
return providerFilteredRepos;
|
||||
}
|
||||
|
||||
// For URL inputs, use the processed search input for filtering
|
||||
// Filter by input keyword
|
||||
const filterText = inputValue.startsWith("https://")
|
||||
? processedSearchInput
|
||||
: inputValue;
|
||||
|
||||
return repositories.filter((repo) =>
|
||||
return providerFilteredRepos.filter((repo) =>
|
||||
repo.full_name.toLowerCase().includes(filterText.toLowerCase()),
|
||||
);
|
||||
}, [storedRecentRepositories, provider, inputValue, processedSearchInput]);
|
||||
|
||||
// Helper function to prioritize recent repositories at the top
|
||||
const prioritizeRecentRepositories = useCallback(
|
||||
(repoList: GitRepository[]) => {
|
||||
const recentRepoIds = new Set(recentRepositories.map((repo) => repo.id));
|
||||
const recentRepos = repoList.filter((repo) => recentRepoIds.has(repo.id));
|
||||
const otherRepos = repoList.filter((repo) => !recentRepoIds.has(repo.id));
|
||||
return [...recentRepos, ...otherRepos];
|
||||
},
|
||||
[recentRepositories],
|
||||
);
|
||||
|
||||
// Filter repositories based on input value
|
||||
const filteredRepositories = useMemo(() => {
|
||||
let baseRepositories: GitRepository[];
|
||||
|
||||
// If we have URL search results, show them directly (no filtering needed)
|
||||
if (urlSearchResults.length > 0) {
|
||||
baseRepositories = repositories;
|
||||
}
|
||||
// If we have a selected repository and the input matches it exactly, show all repositories
|
||||
else if (
|
||||
selectedRepository &&
|
||||
inputValue === selectedRepository.full_name
|
||||
) {
|
||||
baseRepositories = repositories;
|
||||
}
|
||||
// If no input value, show all repositories
|
||||
else if (!inputValue || !inputValue.trim()) {
|
||||
baseRepositories = repositories;
|
||||
}
|
||||
// For URL inputs, use the processed search input for filtering
|
||||
else {
|
||||
const filterText = inputValue.startsWith("https://")
|
||||
? processedSearchInput
|
||||
: inputValue;
|
||||
|
||||
baseRepositories = repositories.filter((repo) =>
|
||||
repo.full_name.toLowerCase().includes(filterText.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// Prioritize recent repositories at the top
|
||||
return prioritizeRecentRepositories(baseRepositories);
|
||||
}, [
|
||||
repositories,
|
||||
inputValue,
|
||||
selectedRepository,
|
||||
urlSearchResults,
|
||||
processedSearchInput,
|
||||
prioritizeRecentRepositories,
|
||||
]);
|
||||
|
||||
// Handle selection
|
||||
@@ -240,7 +284,6 @@ export function GitRepoDropdown({
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
isHighlighted={itemHighlightedIndex === index}
|
||||
isSelected={itemSelectedItem?.id === item.id}
|
||||
getItemProps={itemGetItemProps}
|
||||
getDisplayText={(repo) => repo.full_name}
|
||||
@@ -251,12 +294,27 @@ export function GitRepoDropdown({
|
||||
const renderEmptyState = (emptyInputValue: string) => (
|
||||
<EmptyState
|
||||
inputValue={emptyInputValue}
|
||||
searchMessage={t(I18nKey.MICROAGENT$NO_REPOSITORY_FOUND)}
|
||||
searchMessage={t(I18nKey.HOME$NO_REPOSITORY_FOUND)}
|
||||
emptyMessage={t(I18nKey.COMMON$NO_REPOSITORY)}
|
||||
testId="git-repo-dropdown-empty"
|
||||
/>
|
||||
);
|
||||
|
||||
// Create sticky top item for recent repositories
|
||||
const stickyTopItem = useMemo(() => {
|
||||
if (recentRepositories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text className="text-xs text-[#FAFAFA] font-semibold leading-4 pl-2">
|
||||
{t(I18nKey.COMMON$MOST_RECENT)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}, [recentRepositories, localSelectedItem, getItemProps, t]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
@@ -309,8 +367,10 @@ export function GitRepoDropdown({
|
||||
menuRef={menuRef}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
stickyTopItem={stickyTopItem}
|
||||
stickyFooterItem={stickyFooterItem}
|
||||
testId="git-repo-dropdown-menu"
|
||||
numberOfRecentItems={recentRepositories.length}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={isError} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import RepoForkedIcon from "#/icons/repo-forked.svg?react";
|
||||
import { GitProviderDropdown } from "./git-provider-dropdown";
|
||||
import { GitBranchDropdown } from "./git-branch-dropdown";
|
||||
import { GitRepoDropdown } from "./git-repo-dropdown";
|
||||
import { useHomeStore } from "#/stores/home-store";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@@ -34,6 +35,7 @@ export function RepositorySelectionForm({
|
||||
React.useState<Provider | null>(null);
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
const { addRecentRepository } = useHomeStore();
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
@@ -168,7 +170,12 @@ export function RepositorySelectionForm({
|
||||
(providers.length > 1 && !selectedProvider) ||
|
||||
isLoadingSettings
|
||||
}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
// Persist the repository to recent repositories when launching
|
||||
if (selectedRepository) {
|
||||
addRecentRepository(selectedRepository);
|
||||
}
|
||||
|
||||
createConversation(
|
||||
{
|
||||
repository: {
|
||||
@@ -181,8 +188,8 @@ export function RepositorySelectionForm({
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="w-full font-semibold"
|
||||
>
|
||||
{!isCreatingConversation && "Launch"}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cn } from "#/utils/utils";
|
||||
interface DropdownItemProps<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
isHighlighted: boolean;
|
||||
isSelected: boolean;
|
||||
getItemProps: <Options>(options: any & Options) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getDisplayText: (item: T) => string;
|
||||
@@ -17,7 +16,6 @@ interface DropdownItemProps<T> {
|
||||
export function DropdownItem<T>({
|
||||
item,
|
||||
index,
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
getItemProps,
|
||||
getDisplayText,
|
||||
@@ -35,7 +33,6 @@ export function DropdownItem<T>({
|
||||
: "px-2 py-2 cursor-pointer text-sm rounded-md mx-0 my-0.5",
|
||||
"text-white focus:outline-none font-normal",
|
||||
{
|
||||
"bg-[#5C5D62]": isHighlighted && !isSelected,
|
||||
"bg-[#C9B974] text-black": isSelected,
|
||||
"hover:bg-[#5C5D62]": !isSelected,
|
||||
"hover:bg-[#C9B974] hover:text-black": isSelected,
|
||||
|
||||
@@ -29,8 +29,10 @@ export interface GenericDropdownMenuProps<T> {
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => React.ReactNode;
|
||||
renderEmptyState: (inputValue: string) => React.ReactNode;
|
||||
stickyTopItem?: React.ReactNode;
|
||||
stickyFooterItem?: React.ReactNode;
|
||||
testId?: string;
|
||||
numberOfRecentItems?: number;
|
||||
}
|
||||
|
||||
export function GenericDropdownMenu<T>({
|
||||
@@ -45,13 +47,15 @@ export function GenericDropdownMenu<T>({
|
||||
menuRef,
|
||||
renderItem,
|
||||
renderEmptyState,
|
||||
stickyTopItem,
|
||||
stickyFooterItem,
|
||||
testId,
|
||||
numberOfRecentItems = 0,
|
||||
}: GenericDropdownMenuProps<T>) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hasItems = filteredItems.length > 0;
|
||||
const showEmptyState = !hasItems && !stickyFooterItem;
|
||||
const showEmptyState = !hasItems && !stickyTopItem && !stickyFooterItem;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -59,7 +63,7 @@ export function GenericDropdownMenu<T>({
|
||||
className={cn(
|
||||
"absolute z-10 w-full bg-[#454545] border border-[#727987] rounded-lg shadow-none",
|
||||
"focus:outline-none mt-1 z-[9999]",
|
||||
stickyFooterItem ? "max-h-60" : "max-h-60",
|
||||
stickyTopItem || stickyFooterItem ? "max-h-60" : "max-h-60",
|
||||
)}
|
||||
>
|
||||
<ul
|
||||
@@ -68,23 +72,36 @@ export function GenericDropdownMenu<T>({
|
||||
ref: menuRef,
|
||||
className: cn(
|
||||
"w-full overflow-auto p-1",
|
||||
stickyFooterItem ? "max-h-[calc(15rem-3rem)]" : "max-h-60", // Reserve space for sticky footer
|
||||
stickyTopItem || stickyFooterItem
|
||||
? "max-h-[calc(15rem-3rem)]"
|
||||
: "max-h-60", // Reserve space for sticky items
|
||||
),
|
||||
onScroll,
|
||||
"data-testid": testId,
|
||||
})}
|
||||
>
|
||||
{showEmptyState
|
||||
? renderEmptyState(inputValue)
|
||||
: filteredItems.map((item, index) =>
|
||||
renderItem(
|
||||
item,
|
||||
index,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getItemProps,
|
||||
),
|
||||
)}
|
||||
{showEmptyState ? (
|
||||
renderEmptyState(inputValue)
|
||||
) : (
|
||||
<>
|
||||
{stickyTopItem}
|
||||
{filteredItems.map((item, index) => (
|
||||
<>
|
||||
{renderItem(
|
||||
item,
|
||||
index,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getItemProps,
|
||||
)}
|
||||
{numberOfRecentItems > 0 &&
|
||||
index === numberOfRecentItems - 1 && (
|
||||
<div className="border-b border-[#727987] bg-[#454545] pb-1 mb-1 h-[1px]" />
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
{stickyFooterItem && (
|
||||
<div className="border-t border-[#727987] bg-[#454545] p-1 rounded-b-lg">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SuggestedTask } from "#/utils/types";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { TaskIssueNumber } from "./task-issue-number";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
const getTaskTypeMap = (
|
||||
@@ -21,7 +21,7 @@ interface TaskCardProps {
|
||||
}
|
||||
|
||||
export function TaskCard({ task }: TaskCardProps) {
|
||||
const { setOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { setOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { mutate: createConversation } = useCreateConversation();
|
||||
const isCreatingConversation = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -9,7 +9,12 @@ import { GitProviderDropdown } from "#/components/features/home/git-provider-dro
|
||||
import { useMicroagentManagementStore } from "#/state/microagent-management-store";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import {
|
||||
cn,
|
||||
shouldIncludeRepository,
|
||||
getOpenHandsQuery,
|
||||
hasOpenHandsSuffix,
|
||||
} from "#/utils/utils";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
@@ -55,6 +60,16 @@ export function MicroagentManagementSidebar({
|
||||
const { data: searchResults, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(debouncedSearchQuery, selectedProvider, false, 500); // Increase page size to 500 to to retrieve all search results. This should be optimized in the future.
|
||||
|
||||
const {
|
||||
data: userAndOrgLevelRepositorySearchResults,
|
||||
isLoading: isUserAndOrgLevelRepositoryLoading,
|
||||
} = useSearchRepositories(
|
||||
getOpenHandsQuery(selectedProvider),
|
||||
selectedProvider,
|
||||
false,
|
||||
500,
|
||||
);
|
||||
|
||||
// Auto-select provider if there's only one
|
||||
useEffect(() => {
|
||||
if (providers.length > 0 && !selectedProvider) {
|
||||
@@ -67,11 +82,27 @@ export function MicroagentManagementSidebar({
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
// Helper function to filter repositories by search query
|
||||
const filterRepositoriesByQuery = (
|
||||
inputRepositories: GitRepository[],
|
||||
query: string,
|
||||
): GitRepository[] => {
|
||||
if (!query.trim()) {
|
||||
return inputRepositories;
|
||||
}
|
||||
|
||||
const sanitizedQuery = sanitizeQuery(query);
|
||||
return inputRepositories.filter((repository: GitRepository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
};
|
||||
|
||||
// Filter repositories based on search query and available data
|
||||
const filteredRepositories = useMemo(() => {
|
||||
// If we have search results, use them directly (no filtering needed)
|
||||
// If we have search results, apply client-side filtering for exact matches
|
||||
if (debouncedSearchQuery && searchResults && searchResults.length > 0) {
|
||||
return searchResults;
|
||||
return filterRepositoriesByQuery(searchResults, debouncedSearchQuery);
|
||||
}
|
||||
|
||||
// If no search query or no search results, use paginated repositories
|
||||
@@ -80,56 +111,65 @@ export function MicroagentManagementSidebar({
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
// If no search query, return all repositories
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
return allRepositories;
|
||||
}
|
||||
|
||||
// Fallback to client-side filtering if search didn't return results
|
||||
const sanitizedQuery = sanitizeQuery(debouncedSearchQuery);
|
||||
return allRepositories.filter((repository: GitRepository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
// Apply filtering to paginated repositories
|
||||
return filterRepositoriesByQuery(allRepositories, debouncedSearchQuery);
|
||||
}, [repositories, debouncedSearchQuery, searchResults]);
|
||||
|
||||
// Process personal and organization repositories from search results
|
||||
useEffect(() => {
|
||||
if (!filteredRepositories?.length) {
|
||||
if (!userAndOrgLevelRepositorySearchResults?.length) {
|
||||
setPersonalRepositories([]);
|
||||
setOrganizationRepositories([]);
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const personalRepos: GitRepository[] = [];
|
||||
const organizationRepos: GitRepository[] = [];
|
||||
|
||||
// Process personal repositories with exact match filtering
|
||||
if (userAndOrgLevelRepositorySearchResults?.length) {
|
||||
userAndOrgLevelRepositorySearchResults.forEach((repo: GitRepository) => {
|
||||
if (
|
||||
hasOpenHandsSuffix(repo, selectedProvider) &&
|
||||
shouldIncludeRepository(repo, debouncedSearchQuery)
|
||||
) {
|
||||
if (repo.owner_type === "user") {
|
||||
personalRepos.push(repo);
|
||||
} else if (repo.owner_type === "organization") {
|
||||
organizationRepos.push(repo);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setPersonalRepositories(personalRepos);
|
||||
setOrganizationRepositories(organizationRepos);
|
||||
}, [
|
||||
userAndOrgLevelRepositorySearchResults,
|
||||
selectedProvider,
|
||||
debouncedSearchQuery,
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
]);
|
||||
|
||||
// Process other repositories (non-OpenHands repositories) from filteredRepositories
|
||||
useEffect(() => {
|
||||
if (!filteredRepositories?.length) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const otherRepos: GitRepository[] = [];
|
||||
|
||||
filteredRepositories.forEach((repo: GitRepository) => {
|
||||
const hasOpenHandsSuffix =
|
||||
selectedProvider === "gitlab"
|
||||
? repo.full_name.endsWith("/openhands-config")
|
||||
: repo.full_name.endsWith("/.openhands");
|
||||
|
||||
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
|
||||
personalRepos.push(repo);
|
||||
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
|
||||
organizationRepos.push(repo);
|
||||
} else {
|
||||
// Only include repositories that don't have the OpenHands suffix
|
||||
if (!hasOpenHandsSuffix(repo, selectedProvider)) {
|
||||
otherRepos.push(repo);
|
||||
}
|
||||
});
|
||||
|
||||
setPersonalRepositories(personalRepos);
|
||||
setOrganizationRepositories(organizationRepos);
|
||||
setRepositories(otherRepos);
|
||||
}, [
|
||||
filteredRepositories,
|
||||
selectedProvider,
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
setRepositories,
|
||||
]);
|
||||
}, [filteredRepositories, selectedProvider, setRepositories]);
|
||||
|
||||
// Handle scroll to bottom for pagination
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
@@ -199,7 +239,7 @@ export function MicroagentManagementSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{isLoading || isUserAndOrgLevelRepositoryLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 flex-1">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-white">
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
isStatusUpdate,
|
||||
isUserMessage,
|
||||
} from "#/types/core/guards";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
|
||||
export type WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
|
||||
|
||||
@@ -131,11 +131,10 @@ export function WsClientProvider({
|
||||
conversationId,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { setErrorMessage, removeErrorMessage } = useWSErrorMessage();
|
||||
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const queryClient = useQueryClient();
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const connectErrorCountRef = React.useRef<number>(0);
|
||||
const [webSocketStatus, setWebSocketStatus] =
|
||||
React.useState<WebSocketStatus>("DISCONNECTED");
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
@@ -160,33 +159,9 @@ export function WsClientProvider({
|
||||
function handleConnect() {
|
||||
setWebSocketStatus("CONNECTED");
|
||||
removeErrorMessage();
|
||||
// Reset error count on successful connection
|
||||
connectErrorCountRef.current = 0;
|
||||
console.log('[WS_DEBUG] Connection error count reset to 0');
|
||||
}
|
||||
|
||||
function handleMessage(event: Record<string, unknown>) {
|
||||
// Log important message events for debugging
|
||||
if (event.event_type === "message" || event.action === "message") {
|
||||
const sender = (event as any).sender || "unknown";
|
||||
const content = (event as any).content || (event as any).message || "";
|
||||
console.log('[MESSAGE_DEBUG] Message received:', {
|
||||
sender,
|
||||
content: content.substring(0, 100),
|
||||
eventId: (event as any).id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Log agent state changes
|
||||
if (isAgentStateChangeObservation(event)) {
|
||||
console.log('[AGENT_DEBUG] Agent state changed:', {
|
||||
newState: event.extras.agent_state,
|
||||
eventId: event.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
handleAssistantMessage(event);
|
||||
|
||||
if (isOpenHandsEvent(event)) {
|
||||
@@ -227,11 +202,6 @@ export function WsClientProvider({
|
||||
}
|
||||
|
||||
if (isUserMessage(event)) {
|
||||
console.log('[MESSAGE_DEBUG] User message confirmed:', {
|
||||
content: (event as any).content?.substring(0, 100),
|
||||
eventId: (event as any).id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
removeOptimisticUserMessage();
|
||||
}
|
||||
|
||||
@@ -298,50 +268,23 @@ export function WsClientProvider({
|
||||
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
|
||||
}
|
||||
|
||||
function handleError(data: unknown, isConnectionError = false) {
|
||||
function handleError(data: unknown) {
|
||||
// set status
|
||||
setWebSocketStatus("DISCONNECTED");
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
|
||||
// For connection errors during STARTING, use retry logic
|
||||
if (isConnectionError) {
|
||||
connectErrorCountRef.current += 1;
|
||||
const errorCount = connectErrorCountRef.current;
|
||||
const conversationStatus = conversation?.status;
|
||||
|
||||
console.log('[WS_DEBUG] Connection error handling:', {
|
||||
errorCount,
|
||||
conversationStatus,
|
||||
willShowError: errorCount > 3 && conversationStatus !== "STARTING",
|
||||
});
|
||||
|
||||
// Only show error banner if:
|
||||
// 1. We've failed more than 3 times (persistent failure)
|
||||
// 2. AND we're not in STARTING state (where failures are expected)
|
||||
if (errorCount > 3 && conversationStatus !== "STARTING") {
|
||||
setErrorMessage(
|
||||
hasValidMessageProperty(data)
|
||||
? data.message
|
||||
: "Unable to establish WebSocket connection. Messages will be sent via HTTP.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Non-connection errors always show immediately
|
||||
setErrorMessage(
|
||||
hasValidMessageProperty(data)
|
||||
? data.message
|
||||
: "An unknown error occurred on the WebSocket connection.",
|
||||
);
|
||||
}
|
||||
setErrorMessage(
|
||||
hasValidMessageProperty(data)
|
||||
? data.message
|
||||
: "An unknown error occurred on the WebSocket connection.",
|
||||
);
|
||||
|
||||
// check if something went wrong with the conversation.
|
||||
refetchConversation();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
console.log('[WS_DEBUG] Conversation ID changed:', conversationId);
|
||||
lastEventRef.current = null;
|
||||
connectErrorCountRef.current = 0; // Reset error count for new conversation
|
||||
|
||||
// reset events when conversationId changes
|
||||
setEvents([]);
|
||||
@@ -350,70 +293,34 @@ export function WsClientProvider({
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
console.log('[WS_DEBUG] WebSocket connection effect triggered:', {
|
||||
conversationId,
|
||||
conversation: conversation ? {
|
||||
id: conversation.id,
|
||||
status: conversation.status,
|
||||
runtime_status: conversation.runtime_status,
|
||||
session_api_key: conversation.session_api_key ? 'present' : 'missing',
|
||||
url: conversation.url
|
||||
} : 'null',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
}
|
||||
|
||||
// Clear error messages when conversation is intentionally stopped
|
||||
if (conversation && conversation.status === "STOPPED") {
|
||||
console.log('[WS_DEBUG] Conversation STOPPED, disconnecting WebSocket');
|
||||
removeErrorMessage();
|
||||
setWebSocketStatus("DISCONNECTED");
|
||||
return () => undefined; // conversation intentionally stopped
|
||||
}
|
||||
|
||||
// Allow WebSocket connection during STARTING status to receive real-time updates
|
||||
// Set connecting status when conversation is starting
|
||||
if (conversation && conversation.status === "STARTING") {
|
||||
console.log('[WS_DEBUG] Conversation STARTING, will attempt WebSocket connection');
|
||||
removeErrorMessage();
|
||||
setWebSocketStatus("CONNECTING");
|
||||
// Don't return early - let it continue to establish connection
|
||||
return () => undefined; // conversation is starting, will connect when ready
|
||||
}
|
||||
|
||||
// Only connect when conversation exists and is not stopped
|
||||
// Only connect when conversation is fully loaded and running
|
||||
if (
|
||||
!conversation ||
|
||||
(conversation.status !== "RUNNING" && conversation.status !== "STARTING") ||
|
||||
conversation.status !== "RUNNING" ||
|
||||
!conversation.runtime_status ||
|
||||
conversation.runtime_status === "STATUS$STOPPED"
|
||||
) {
|
||||
console.log('[WS_DEBUG] NOT connecting WebSocket because:', {
|
||||
hasConversation: !!conversation,
|
||||
status: conversation?.status,
|
||||
runtime_status: conversation?.runtime_status,
|
||||
reason: !conversation ? 'no conversation' :
|
||||
(conversation.status !== "RUNNING" && conversation.status !== "STARTING") ? `status is ${conversation.status}, not RUNNING/STARTING` :
|
||||
'runtime is stopped'
|
||||
});
|
||||
return () => undefined; // conversation not ready for WebSocket connection
|
||||
}
|
||||
|
||||
// Check if session_api_key is available - required for WebSocket authentication
|
||||
if (!conversation.session_api_key) {
|
||||
console.log('[WS_DEBUG] No session_api_key yet, skipping WebSocket connection', {
|
||||
conversationId,
|
||||
status: conversation.status,
|
||||
hasUrl: !!conversation.url,
|
||||
});
|
||||
// This effect runs whenever conversation object changes. Since we're not setting any state that
|
||||
// would cause re-renders, we rely on the existing polling to refetch conversation and trigger this
|
||||
// effect again when session_api_key becomes available.
|
||||
return () => undefined; // Wait for session_api_key to become available
|
||||
}
|
||||
|
||||
console.log('[WS_DEBUG] ESTABLISHING WebSocket connection for conversation with status:', conversation.status);
|
||||
|
||||
let sio = sioRef.current;
|
||||
|
||||
if (sio?.connected) {
|
||||
@@ -431,17 +338,6 @@ export function WsClientProvider({
|
||||
session_api_key: conversation.session_api_key, // Have to set here because socketio doesn't support custom headers. :(
|
||||
};
|
||||
|
||||
// Debug: Check critical connection parameters
|
||||
console.log('[WS_DEBUG] WebSocket connection parameters:', {
|
||||
hasUrl: !!conversation.url,
|
||||
url: conversation.url,
|
||||
hasSessionApiKey: !!conversation.session_api_key,
|
||||
sessionApiKeyLength: conversation.session_api_key?.length,
|
||||
conversationStatus: conversation.status,
|
||||
runtimeStatus: conversation.runtime_status,
|
||||
query,
|
||||
});
|
||||
|
||||
let baseUrl: string | null = null;
|
||||
let socketPath: string;
|
||||
if (conversation.url && !conversation.url.startsWith("/")) {
|
||||
@@ -463,51 +359,11 @@ export function WsClientProvider({
|
||||
query,
|
||||
});
|
||||
|
||||
console.log('[WS_DEBUG] Attempting WebSocket connection:', {
|
||||
baseUrl,
|
||||
socketPath,
|
||||
fullUrl: `ws://${baseUrl}${socketPath}`,
|
||||
hasSessionApiKey: !!query.session_api_key,
|
||||
conversationId: query.conversation_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
sio.on("connect", () => {
|
||||
console.log('[WS_DEBUG] ✅ WebSocket CONNECTED successfully!', {
|
||||
socketId: sio.id,
|
||||
connected: sio.connected,
|
||||
conversationStatus: conversation.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
handleConnect();
|
||||
});
|
||||
sio.on("connect", handleConnect);
|
||||
sio.on("oh_event", handleMessage);
|
||||
sio.on("connect_error", (error) => {
|
||||
console.log('[WS_DEBUG] ❌ WebSocket connect_error:', {
|
||||
errorMessage: error.message,
|
||||
errorType: error.type,
|
||||
errorData: error.data,
|
||||
conversationStatus: conversation.status,
|
||||
hasUrl: !!conversation.url,
|
||||
hasSessionApiKey: !!conversation.session_api_key,
|
||||
errorCount: connectErrorCountRef.current + 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
handleError(error, true); // true = isConnectionError
|
||||
});
|
||||
sio.on("connect_failed", (error) => {
|
||||
console.log('[WS_DEBUG] WebSocket connect_failed:', error);
|
||||
handleError(error, true); // true = isConnectionError
|
||||
});
|
||||
sio.on("disconnect", (reason) => {
|
||||
console.log('[WS_DEBUG] ⚠️ WebSocket disconnected:', {
|
||||
reason,
|
||||
wasConnected: sio.connected,
|
||||
conversationStatus: conversation.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
handleDisconnect(reason);
|
||||
});
|
||||
sio.on("connect_error", handleError);
|
||||
sio.on("connect_failed", handleError);
|
||||
sio.on("disconnect", handleDisconnect);
|
||||
|
||||
sioRef.current = sio;
|
||||
|
||||
|
||||
@@ -6,21 +6,11 @@ import ConversationService from "#/api/conversation-service/conversation-service
|
||||
export const useActiveConversation = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const userConversation = useUserConversation(conversationId, (query) => {
|
||||
const status = query.state.data?.status;
|
||||
console.log('[CONVERSATION_DEBUG] Polling conversation:', {
|
||||
conversationId,
|
||||
status,
|
||||
runtime_status: query.state.data?.runtime_status,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (status === "STARTING") {
|
||||
console.log('[CONVERSATION_DEBUG] Status is STARTING, polling every 3s');
|
||||
if (query.state.data?.status === "STARTING") {
|
||||
return 3000; // 3 seconds
|
||||
}
|
||||
// TODO: Return conversation title as a WS event to avoid polling
|
||||
// This was changed from 5 minutes to 30 seconds to poll for updated conversation title after an auto update
|
||||
console.log('[CONVERSATION_DEBUG] Status is not STARTING, polling every 30s');
|
||||
return 30000; // 30 seconds
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export function useSearchRepositories(
|
||||
query: string,
|
||||
selectedProvider?: Provider | null,
|
||||
disabled?: boolean,
|
||||
pageSize: number = 3,
|
||||
pageSize: number = 100,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const useOptimisticUserMessage = () => {
|
||||
const queryKey = ["optimistic_user_message"] as const;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setOptimisticUserMessage = (message: string) => {
|
||||
queryClient.setQueryData<string>(queryKey, message);
|
||||
};
|
||||
|
||||
const getOptimisticUserMessage = () =>
|
||||
queryClient.getQueryData<string>(queryKey);
|
||||
|
||||
const removeOptimisticUserMessage = () => {
|
||||
queryClient.removeQueries({ queryKey });
|
||||
};
|
||||
|
||||
return {
|
||||
setOptimisticUserMessage,
|
||||
getOptimisticUserMessage,
|
||||
removeOptimisticUserMessage,
|
||||
};
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const useWSErrorMessage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setErrorMessage = (message: string) => {
|
||||
queryClient.setQueryData<string>(["error_message"], message);
|
||||
};
|
||||
|
||||
const getErrorMessage = () =>
|
||||
queryClient.getQueryData<string>(["error_message"]);
|
||||
|
||||
const removeErrorMessage = () => {
|
||||
queryClient.removeQueries({ queryKey: ["error_message"] });
|
||||
};
|
||||
|
||||
return {
|
||||
setErrorMessage,
|
||||
getErrorMessage,
|
||||
removeErrorMessage,
|
||||
};
|
||||
};
|
||||
@@ -916,4 +916,6 @@ export enum I18nKey {
|
||||
COMMON$START_RUNTIME = "COMMON$START_RUNTIME",
|
||||
COMMON$JUPYTER_EMPTY_MESSAGE = "COMMON$JUPYTER_EMPTY_MESSAGE",
|
||||
COMMON$CONFIRMATION_MODE_ENABLED = "COMMON$CONFIRMATION_MODE_ENABLED",
|
||||
COMMON$MOST_RECENT = "COMMON$MOST_RECENT",
|
||||
HOME$NO_REPOSITORY_FOUND = "HOME$NO_REPOSITORY_FOUND",
|
||||
}
|
||||
|
||||
@@ -9728,20 +9728,20 @@
|
||||
"uk": "або перегляньте"
|
||||
},
|
||||
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED": {
|
||||
"en": "Stop button pressed. The action has not been executed.",
|
||||
"ja": "停止ボタンが押されました。アクションは実行されていません。",
|
||||
"zh-CN": "按下了停止按钮。操作尚未执行。",
|
||||
"zh-TW": "按下了停止按鈕。操作尚未執行。",
|
||||
"ko-KR": "중지 버튼이 눌렸습니다. 작업이 실행되지 않았습니다.",
|
||||
"no": "Stoppknappen ble trykket. Handlingen har ikke blitt utført.",
|
||||
"it": "Pulsante di arresto premuto. L'azione non è stata eseguita.",
|
||||
"pt": "Botão de parar pressionado. A ação não foi executada.",
|
||||
"es": "Botón de detener presionado. La acción no se ha ejecutado.",
|
||||
"ar": "تم الضغط على زر التوقف. لم يتم تنفيذ الإجراء.",
|
||||
"fr": "Bouton d'arrêt appuyé. L'action n'a pas été exécutée.",
|
||||
"tr": "Durdurma düğmesine basıldı. Eylem yürütülmedi.",
|
||||
"de": "Stopp-Taste gedrückt. Die Aktion wurde nicht ausgeführt.",
|
||||
"uk": "Натиснуто кнопку зупинки. Дію не виконано."
|
||||
"en": "Pause button pressed. Agent is stopped. The action has not been executed.",
|
||||
"ja": "一時停止ボタンが押されました。エージェントは停止しています。アクションは実行されていません。",
|
||||
"zh-CN": "已按下暂停按钮。代理已停止。该操作尚未执行。",
|
||||
"zh-TW": "已按下暫停按鈕。代理已停止。該操作尚未執行。",
|
||||
"ko-KR": "일시정지 버튼이 눌렸습니다. 에이전트가 중지되었습니다. 작업이 실행되지 않았습니다.",
|
||||
"no": "Pauseknappen ble trykket. Agenten er stoppet. Handlingen har ikke blitt utført.",
|
||||
"it": "Pulsante di pausa premuto. L'agente è fermo. L'azione non è stata eseguita.",
|
||||
"pt": "Botão de pausa pressionado. O agente está parado. A ação não foi executada.",
|
||||
"es": "Se presionó el botón de pausa. El agente está detenido. La acción no se ha ejecutado.",
|
||||
"ar": "تم الضغط على زر الإيقاف المؤقت. تم إيقاف الوكيل. لم يتم تنفيذ الإجراء.",
|
||||
"fr": "Bouton pause enfoncé. L'agent est arrêté. L'action n'a pas été exécutée.",
|
||||
"tr": "Duraklat düğmesine basıldı. Ajan durduruldu. Eylem gerçekleştirilmedi.",
|
||||
"de": "Pausentaste gedrückt. Agent ist gestoppt. Die Aktion wurde nicht ausgeführt.",
|
||||
"uk": "Натиснуто кнопку паузи. Агент зупинений. Дію не виконано."
|
||||
},
|
||||
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR": {
|
||||
"en": "The action has not been executed due to a runtime error. The runtime system may have crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
|
||||
@@ -14654,5 +14654,37 @@
|
||||
"tr": "Onay modu etkinleştirildi",
|
||||
"de": "Bestätigungsmodus aktiviert",
|
||||
"uk": "Режим підтвердження увімкнено"
|
||||
},
|
||||
"COMMON$MOST_RECENT": {
|
||||
"en": "Most Recent",
|
||||
"ja": "最新",
|
||||
"zh-CN": "最新",
|
||||
"zh-TW": "最新",
|
||||
"ko-KR": "최신",
|
||||
"no": "Nyeste",
|
||||
"it": "Più recente",
|
||||
"pt": "Mais recente",
|
||||
"es": "Más reciente",
|
||||
"ar": "الأحدث",
|
||||
"fr": "Le plus récent",
|
||||
"tr": "En Son",
|
||||
"de": "Neueste",
|
||||
"uk": "Найновіше"
|
||||
},
|
||||
"HOME$NO_REPOSITORY_FOUND": {
|
||||
"en": "No repository found to launch conversation",
|
||||
"ja": "会話を開始するためのリポジトリが見つかりません",
|
||||
"zh-CN": "未找到用于启动会话的存储库",
|
||||
"zh-TW": "未找到用於啟動對話的存儲庫",
|
||||
"ko-KR": "대화를 시작할 저장소를 찾을 수 없습니다",
|
||||
"no": "Ingen repository funnet for å starte samtale",
|
||||
"it": "Nessun repository trovato per avviare la conversazione",
|
||||
"pt": "Nenhum repositório encontrado para iniciar a conversa",
|
||||
"es": "No se encontró ningún repositorio para iniciar la conversación",
|
||||
"ar": "لم يتم العثور على مستودع لبدء المحادثة",
|
||||
"fr": "Aucun dépôt trouvé pour lancer la conversation",
|
||||
"tr": "Konuşma başlatmak için depo bulunamadı",
|
||||
"de": "Kein Repository gefunden, um das Gespräch zu starten",
|
||||
"uk": "Не знайдено репозиторій для запуску розмови"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,9 @@ const openHandsHandlers = [
|
||||
"gpt-4o-mini",
|
||||
"anthropic/claude-3.5",
|
||||
"anthropic/claude-sonnet-4-20250514",
|
||||
"anthropic/claude-sonnet-4-5-20250929",
|
||||
"openhands/claude-sonnet-4-20250514",
|
||||
"openhands/claude-sonnet-4-5-20250929",
|
||||
"sambanova/Meta-Llama-3.1-8B-Instruct",
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -88,13 +88,15 @@ function GitChanges() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
gitChanges.map((change) => (
|
||||
<FileDiffViewer
|
||||
key={change.path}
|
||||
path={change.path}
|
||||
type={change.status}
|
||||
/>
|
||||
))
|
||||
gitChanges
|
||||
.slice(0, 100)
|
||||
.map((change) => (
|
||||
<FileDiffViewer
|
||||
key={change.path}
|
||||
path={change.path}
|
||||
type={change.status}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,12 @@ import { useMemo } from "react";
|
||||
import { Outlet, redirect, useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Route } from "./+types/settings";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
import CircuitIcon from "#/icons/u-circuit.svg?react";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { SettingsLayout } from "#/components/features/settings/settings-layout";
|
||||
|
||||
@@ -52,13 +50,6 @@ function SettingsScreen() {
|
||||
const navItems = useMemo(() => {
|
||||
const items = [];
|
||||
if (isSaas) {
|
||||
if (subscriptionAccess) {
|
||||
items.push({
|
||||
icon: <CircuitIcon width={22} height={22} />,
|
||||
to: "/settings",
|
||||
text: "SETTINGS$NAV_LLM" as I18nKey,
|
||||
});
|
||||
}
|
||||
items.push(...SAAS_NAV_ITEMS);
|
||||
} else {
|
||||
items.push(...OSS_NAV_ITEMS);
|
||||
|
||||
30
frontend/src/stores/error-message-store.ts
Normal file
30
frontend/src/stores/error-message-store.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ErrorMessageState {
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
interface ErrorMessageActions {
|
||||
setErrorMessage: (message: string) => void;
|
||||
removeErrorMessage: () => void;
|
||||
}
|
||||
|
||||
type ErrorMessageStore = ErrorMessageState & ErrorMessageActions;
|
||||
|
||||
const initialState: ErrorMessageState = {
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
export const useErrorMessageStore = create<ErrorMessageStore>((set) => ({
|
||||
...initialState,
|
||||
|
||||
setErrorMessage: (message: string) =>
|
||||
set(() => ({
|
||||
errorMessage: message,
|
||||
})),
|
||||
|
||||
removeErrorMessage: () =>
|
||||
set(() => ({
|
||||
errorMessage: null,
|
||||
})),
|
||||
}));
|
||||
53
frontend/src/stores/home-store.ts
Normal file
53
frontend/src/stores/home-store.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
interface HomeState {
|
||||
recentRepositories: GitRepository[];
|
||||
}
|
||||
|
||||
interface HomeActions {
|
||||
addRecentRepository: (repository: GitRepository) => void;
|
||||
clearRecentRepositories: () => void;
|
||||
getRecentRepositories: () => GitRepository[];
|
||||
}
|
||||
|
||||
type HomeStore = HomeState & HomeActions;
|
||||
|
||||
const initialState: HomeState = {
|
||||
recentRepositories: [],
|
||||
};
|
||||
|
||||
export const useHomeStore = create<HomeStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
addRecentRepository: (repository: GitRepository) =>
|
||||
set((state) => {
|
||||
// Remove the repository if it already exists to avoid duplicates
|
||||
const filteredRepos = state.recentRepositories.filter(
|
||||
(repo) => repo.id !== repository.id,
|
||||
);
|
||||
|
||||
// Add the new repository to the beginning and keep only top 3
|
||||
const updatedRepos = [repository, ...filteredRepos].slice(0, 3);
|
||||
|
||||
return {
|
||||
recentRepositories: updatedRepos,
|
||||
};
|
||||
}),
|
||||
|
||||
clearRecentRepositories: () =>
|
||||
set(() => ({
|
||||
recentRepositories: [],
|
||||
})),
|
||||
|
||||
getRecentRepositories: () => get().recentRepositories,
|
||||
}),
|
||||
{
|
||||
name: "home-store", // unique name for localStorage
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
36
frontend/src/stores/optimistic-user-message-store.ts
Normal file
36
frontend/src/stores/optimistic-user-message-store.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface OptimisticUserMessageState {
|
||||
optimisticUserMessage: string | null;
|
||||
}
|
||||
|
||||
interface OptimisticUserMessageActions {
|
||||
setOptimisticUserMessage: (message: string) => void;
|
||||
getOptimisticUserMessage: () => string | null;
|
||||
removeOptimisticUserMessage: () => void;
|
||||
}
|
||||
|
||||
type OptimisticUserMessageStore = OptimisticUserMessageState &
|
||||
OptimisticUserMessageActions;
|
||||
|
||||
const initialState: OptimisticUserMessageState = {
|
||||
optimisticUserMessage: null,
|
||||
};
|
||||
|
||||
export const useOptimisticUserMessageStore = create<OptimisticUserMessageStore>(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setOptimisticUserMessage: (message: string) =>
|
||||
set(() => ({
|
||||
optimisticUserMessage: message,
|
||||
})),
|
||||
|
||||
getOptimisticUserMessage: () => get().optimisticUserMessage,
|
||||
|
||||
removeOptimisticUserMessage: () =>
|
||||
set(() => ({
|
||||
optimisticUserMessage: null,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
183
frontend/src/types/v1/core/base/action.ts
Normal file
183
frontend/src/types/v1/core/base/action.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { ActionBase } from "./base";
|
||||
import { TaskItem } from "./common";
|
||||
|
||||
interface MCPToolAction extends ActionBase<"MCPToolAction"> {
|
||||
/**
|
||||
* Dynamic data fields from the tool call
|
||||
*/
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FinishAction extends ActionBase<"FinishAction"> {
|
||||
/**
|
||||
* Final message to send to the user
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ThinkAction extends ActionBase<"ThinkAction"> {
|
||||
/**
|
||||
* The thought to log
|
||||
*/
|
||||
thought: string;
|
||||
}
|
||||
|
||||
interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
|
||||
/**
|
||||
* The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process.
|
||||
*/
|
||||
command: string;
|
||||
/**
|
||||
* If True, the command is an input to the running process. If False, the command is a bash command to be executed in the terminal. Default is False.
|
||||
*/
|
||||
is_input: boolean;
|
||||
/**
|
||||
* Optional. Sets a maximum time limit (in seconds) for running the command. If the command takes longer than this limit, you’ll be asked whether to continue or stop it.
|
||||
*/
|
||||
timeout: number | null;
|
||||
/**
|
||||
* If True, reset the terminal by creating a new session. Used only when the terminal becomes unresponsive. Note that all previously set environment variables and session state will be lost after reset. Cannot be used with is_input=True.
|
||||
*/
|
||||
reset: boolean;
|
||||
}
|
||||
|
||||
interface StrReplaceEditorAction extends ActionBase<"StrReplaceEditorAction"> {
|
||||
/**
|
||||
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
|
||||
*/
|
||||
command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
|
||||
/**
|
||||
* Absolute path to file or directory.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* Required parameter of `create` command, with the content of the file to be created.
|
||||
*/
|
||||
file_text: string | null;
|
||||
/**
|
||||
* Required parameter of `str_replace` command containing the string in `path` to replace.
|
||||
*/
|
||||
old_str: string | null;
|
||||
/**
|
||||
* Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.
|
||||
*/
|
||||
new_str: string | null;
|
||||
/**
|
||||
* Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. Must be >= 1.
|
||||
*/
|
||||
insert_line: number | null;
|
||||
/**
|
||||
* Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.
|
||||
*/
|
||||
view_range: [number, number] | null;
|
||||
}
|
||||
|
||||
interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
|
||||
/**
|
||||
* The command to execute. `view` shows the current task list. `plan` creates or updates the task list based on provided requirements and progress. Always `view` the current list before making changes.
|
||||
*/
|
||||
command: "view" | "plan";
|
||||
/**
|
||||
* The full task list. Required parameter of `plan` command.
|
||||
*/
|
||||
task_list: TaskItem[];
|
||||
}
|
||||
|
||||
interface BrowserNavigateAction extends ActionBase<"BrowserNavigateAction"> {
|
||||
/**
|
||||
* The URL to navigate to
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Whether to open in a new tab. Default: False
|
||||
*/
|
||||
new_tab: boolean;
|
||||
}
|
||||
|
||||
interface BrowserClickAction extends ActionBase<"BrowserClickAction"> {
|
||||
/**
|
||||
* The index of the element to click (from browser_get_state)
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* Whether to open any resulting navigation in a new tab. Default: False
|
||||
*/
|
||||
new_tab: boolean;
|
||||
}
|
||||
|
||||
interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
|
||||
/**
|
||||
* The index of the input element (from browser_get_state)
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* The text to type
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface BrowserGetStateAction extends ActionBase<"BrowserGetStateAction"> {
|
||||
/**
|
||||
* Whether to include a screenshot of the current page. Default: False
|
||||
*/
|
||||
include_screenshot: boolean;
|
||||
}
|
||||
|
||||
interface BrowserGetContentAction
|
||||
extends ActionBase<"BrowserGetContentAction"> {
|
||||
/**
|
||||
* Whether to include links in the content (default: False)
|
||||
*/
|
||||
extract_links: boolean;
|
||||
/**
|
||||
* Character index to start from in the page content (default: 0)
|
||||
*/
|
||||
start_from_char: number;
|
||||
}
|
||||
|
||||
interface BrowserScrollAction extends ActionBase<"BrowserScrollAction"> {
|
||||
/**
|
||||
* Direction to scroll. Options: 'up', 'down'. Default: 'down'
|
||||
*/
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
interface BrowserGoBackAction extends ActionBase<"BrowserGoBackAction"> {
|
||||
// No additional properties - this action has no parameters
|
||||
}
|
||||
|
||||
interface BrowserListTabsAction extends ActionBase<"BrowserListTabsAction"> {
|
||||
// No additional properties - this action has no parameters
|
||||
}
|
||||
|
||||
interface BrowserSwitchTabAction extends ActionBase<"BrowserSwitchTabAction"> {
|
||||
/**
|
||||
* 4 Character Tab ID of the tab to switch to (from browser_list_tabs)
|
||||
*/
|
||||
tab_id: string;
|
||||
}
|
||||
|
||||
interface BrowserCloseTabAction extends ActionBase<"BrowserCloseTabAction"> {
|
||||
/**
|
||||
* 4 Character Tab ID of the tab to close (from browser_list_tabs)
|
||||
*/
|
||||
tab_id: string;
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| MCPToolAction
|
||||
| FinishAction
|
||||
| ThinkAction
|
||||
| ExecuteBashAction
|
||||
| StrReplaceEditorAction
|
||||
| TaskTrackerAction
|
||||
| BrowserNavigateAction
|
||||
| BrowserClickAction
|
||||
| BrowserTypeAction
|
||||
| BrowserGetStateAction
|
||||
| BrowserGetContentAction
|
||||
| BrowserScrollAction
|
||||
| BrowserGoBackAction
|
||||
| BrowserListTabsAction
|
||||
| BrowserSwitchTabAction
|
||||
| BrowserCloseTabAction;
|
||||
36
frontend/src/types/v1/core/base/base.ts
Normal file
36
frontend/src/types/v1/core/base/base.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
type EventType =
|
||||
| "MCPTool"
|
||||
| "Finish"
|
||||
| "Think"
|
||||
| "ExecuteBash"
|
||||
| "StrReplaceEditor"
|
||||
| "TaskTracker";
|
||||
|
||||
type ActionOnlyType =
|
||||
| "BrowserNavigate"
|
||||
| "BrowserClick"
|
||||
| "BrowserType"
|
||||
| "BrowserGetState"
|
||||
| "BrowserGetContent"
|
||||
| "BrowserScroll"
|
||||
| "BrowserGoBack"
|
||||
| "BrowserListTabs"
|
||||
| "BrowserSwitchTab"
|
||||
| "BrowserCloseTab";
|
||||
|
||||
type ObservationOnlyType = "Browser";
|
||||
|
||||
type ActionEventType = `${ActionOnlyType}Action` | `${EventType}Action`;
|
||||
type ObservationEventType =
|
||||
| `${ObservationOnlyType}Observation`
|
||||
| `${EventType}Observation`;
|
||||
|
||||
export interface ActionBase<T extends ActionEventType = ActionEventType> {
|
||||
kind: T;
|
||||
}
|
||||
|
||||
export interface ObservationBase<
|
||||
T extends ObservationEventType = ObservationEventType,
|
||||
> {
|
||||
kind: T;
|
||||
}
|
||||
77
frontend/src/types/v1/core/base/common.ts
Normal file
77
frontend/src/types/v1/core/base/common.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export interface TaskItem {
|
||||
/**
|
||||
* A brief title for the task.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Additional details or notes about the task.
|
||||
*/
|
||||
notes: string;
|
||||
/**
|
||||
* The current status of the task. One of 'todo', 'in_progress', or 'done'.
|
||||
*/
|
||||
status: "todo" | "in_progress" | "done";
|
||||
}
|
||||
|
||||
export interface CmdOutputMetadata {
|
||||
/**
|
||||
* The exit code of the last executed command
|
||||
*/
|
||||
exit_code: number;
|
||||
/**
|
||||
* The process ID of the last executed command
|
||||
*/
|
||||
pid: number;
|
||||
/**
|
||||
* The username of the current user
|
||||
*/
|
||||
username: string | null;
|
||||
/**
|
||||
* The hostname of the machine
|
||||
*/
|
||||
hostname: string | null;
|
||||
/**
|
||||
* The current working directory
|
||||
*/
|
||||
working_dir: string | null;
|
||||
/**
|
||||
* The path to the current Python interpreter, if any
|
||||
*/
|
||||
py_interpreter_path: string | null;
|
||||
/**
|
||||
* Prefix to add to command output
|
||||
*/
|
||||
prefix: string;
|
||||
/**
|
||||
* Suffix to add to command output
|
||||
*/
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
// Type aliases for event and tool call IDs
|
||||
export type EventID = string;
|
||||
export type ToolCallID = string;
|
||||
|
||||
// Source type for events
|
||||
export type SourceType = "agent" | "user" | "environment";
|
||||
|
||||
// Security risk levels
|
||||
export enum SecurityRisk {
|
||||
UNKNOWN = "UNKNOWN",
|
||||
LOW = "LOW",
|
||||
MEDIUM = "MEDIUM",
|
||||
HIGH = "HIGH",
|
||||
}
|
||||
|
||||
// Content types for LLM messages
|
||||
export interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
cache_prompt?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageContent {
|
||||
type: "image";
|
||||
image_urls: string[];
|
||||
cache_prompt?: boolean;
|
||||
}
|
||||
79
frontend/src/types/v1/core/base/event.ts
Normal file
79
frontend/src/types/v1/core/base/event.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
EventID,
|
||||
SourceType,
|
||||
ToolCallID,
|
||||
TextContent,
|
||||
ImageContent,
|
||||
} from "./common";
|
||||
|
||||
// Base event interface - fundamental properties for all events
|
||||
export interface BaseEvent {
|
||||
/**
|
||||
* Unique event id (ULID/UUID)
|
||||
*/
|
||||
id: EventID;
|
||||
|
||||
/**
|
||||
* Event timestamp (ISO string)
|
||||
*/
|
||||
timestamp: string;
|
||||
|
||||
/**
|
||||
* The source of this event
|
||||
*/
|
||||
source: SourceType;
|
||||
}
|
||||
|
||||
// LLM Message structure
|
||||
export interface Message {
|
||||
role: "user" | "system" | "assistant" | "tool";
|
||||
content: (TextContent | ImageContent)[];
|
||||
cache_enabled?: boolean;
|
||||
vision_enabled?: boolean;
|
||||
tool_calls?: ChatCompletionMessageToolCall[];
|
||||
reasoning_content?: string | null;
|
||||
thinking_blocks?: (ThinkingBlock | RedactedThinkingBlock)[];
|
||||
name?: string;
|
||||
tool_call_id?: ToolCallID;
|
||||
}
|
||||
|
||||
// Tool call structure from LiteLLM
|
||||
export interface ChatCompletionMessageToolCall {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Tool parameter structure from LiteLLM
|
||||
export interface ChatCompletionToolParam {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// Thinking blocks for Anthropic extended thinking feature
|
||||
export interface ThinkingBlock {
|
||||
type: "thinking";
|
||||
/**
|
||||
* The thinking content
|
||||
*/
|
||||
thinking: string;
|
||||
/**
|
||||
* Cryptographic signature for the thinking block
|
||||
*/
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface RedactedThinkingBlock {
|
||||
type: "redacted_thinking";
|
||||
/**
|
||||
* The redacted thinking content
|
||||
*/
|
||||
data: string;
|
||||
}
|
||||
6
frontend/src/types/v1/core/base/index.ts
Normal file
6
frontend/src/types/v1/core/base/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Export all base types
|
||||
export * from "./action";
|
||||
export * from "./base";
|
||||
export * from "./common";
|
||||
export * from "./event";
|
||||
export * from "./observation";
|
||||
136
frontend/src/types/v1/core/base/observation.ts
Normal file
136
frontend/src/types/v1/core/base/observation.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { ObservationBase } from "./base";
|
||||
import {
|
||||
CmdOutputMetadata,
|
||||
TaskItem,
|
||||
TextContent,
|
||||
ImageContent,
|
||||
} from "./common";
|
||||
|
||||
interface MCPToolObservation extends ObservationBase<"MCPToolObservation"> {
|
||||
/**
|
||||
* Content returned from the MCP tool converted to LLM Ready TextContent or ImageContent
|
||||
*/
|
||||
content: Array<TextContent | ImageContent>;
|
||||
/**
|
||||
* Whether the call resulted in an error
|
||||
*/
|
||||
is_error: boolean;
|
||||
/**
|
||||
* Name of the tool that was called
|
||||
*/
|
||||
tool_name: string;
|
||||
}
|
||||
|
||||
interface FinishObservation extends ObservationBase<"FinishObservation"> {
|
||||
/**
|
||||
* Final message sent to the user
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ThinkObservation extends ObservationBase<"ThinkObservation"> {
|
||||
/**
|
||||
* Confirmation message. DEFAULT: "Your thought has been logged."
|
||||
*/
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface BrowserObservation extends ObservationBase<"BrowserObservation"> {
|
||||
/**
|
||||
* The output message from the browser operation
|
||||
*/
|
||||
output: string;
|
||||
/**
|
||||
* Error message if any
|
||||
*/
|
||||
error: string | null;
|
||||
/**
|
||||
* Base64 screenshot data if available
|
||||
*/
|
||||
screenshot_data: string | null;
|
||||
}
|
||||
|
||||
interface ExecuteBashObservation
|
||||
extends ObservationBase<"ExecuteBashObservation"> {
|
||||
/**
|
||||
* The raw output from the tool.
|
||||
*/
|
||||
output: string;
|
||||
/**
|
||||
* The bash command that was executed. Can be empty string if the observation is from a previous command that hit soft timeout and is not yet finished.
|
||||
*/
|
||||
command: string | null;
|
||||
/**
|
||||
* The exit code of the command. -1 indicates the process hit the soft timeout and is not yet finished.
|
||||
*/
|
||||
exit_code: number | null;
|
||||
/**
|
||||
* Whether there was an error during command execution.
|
||||
*/
|
||||
error: boolean;
|
||||
/**
|
||||
* Whether the command execution timed out.
|
||||
*/
|
||||
timeout: boolean;
|
||||
/**
|
||||
* Additional metadata captured from PS1 after command execution.
|
||||
*/
|
||||
metadata: CmdOutputMetadata;
|
||||
}
|
||||
|
||||
interface StrReplaceEditorObservation
|
||||
extends ObservationBase<"StrReplaceEditorObservation"> {
|
||||
/**
|
||||
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
|
||||
*/
|
||||
command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
|
||||
/**
|
||||
* The output message from the tool for the LLM to see.
|
||||
*/
|
||||
output: string;
|
||||
/**
|
||||
* The file path that was edited.
|
||||
*/
|
||||
path: string | null;
|
||||
/**
|
||||
* Indicates if the file previously existed. If not, it was created.
|
||||
*/
|
||||
prev_exist: boolean;
|
||||
/**
|
||||
* The content of the file before the edit.
|
||||
*/
|
||||
old_content: string | null;
|
||||
/**
|
||||
* The content of the file after the edit.
|
||||
*/
|
||||
new_content: string | null;
|
||||
/**
|
||||
* Error message if any.
|
||||
*/
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface TaskTrackerObservation
|
||||
extends ObservationBase<"TaskTrackerObservation"> {
|
||||
/**
|
||||
* The formatted task list or status message.
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* The command that was executed.
|
||||
*/
|
||||
command: string;
|
||||
/**
|
||||
* The current task list.
|
||||
*/
|
||||
task_list: TaskItem[];
|
||||
}
|
||||
|
||||
export type Observation =
|
||||
| MCPToolObservation
|
||||
| FinishObservation
|
||||
| ThinkObservation
|
||||
| BrowserObservation
|
||||
| ExecuteBashObservation
|
||||
| StrReplaceEditorObservation
|
||||
| TaskTrackerObservation;
|
||||
61
frontend/src/types/v1/core/events/action-event.ts
Normal file
61
frontend/src/types/v1/core/events/action-event.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Action } from "../base/action";
|
||||
import { EventID, ToolCallID, SecurityRisk, TextContent } from "../base/common";
|
||||
import {
|
||||
BaseEvent,
|
||||
ChatCompletionMessageToolCall,
|
||||
ThinkingBlock,
|
||||
RedactedThinkingBlock,
|
||||
} from "../base/event";
|
||||
|
||||
export interface ActionEvent extends BaseEvent {
|
||||
/**
|
||||
* The thought process of the agent before taking this action
|
||||
*/
|
||||
thought: TextContent[];
|
||||
|
||||
/**
|
||||
* Intermediate reasoning/thinking content from reasoning models
|
||||
*/
|
||||
reasoning_content?: string | null;
|
||||
|
||||
/**
|
||||
* Anthropic thinking blocks from the LLM response
|
||||
*/
|
||||
thinking_blocks: (ThinkingBlock | RedactedThinkingBlock)[];
|
||||
|
||||
/**
|
||||
* Single action (tool call) returned by LLM
|
||||
*/
|
||||
action: Action;
|
||||
|
||||
/**
|
||||
* The name of the tool being called
|
||||
*/
|
||||
tool_name: string;
|
||||
|
||||
/**
|
||||
* The unique id returned by LLM API for this tool call
|
||||
*/
|
||||
tool_call_id: ToolCallID;
|
||||
|
||||
/**
|
||||
* The tool call received from the LLM response. We keep a copy of it
|
||||
* so it is easier to construct it into LLM message.
|
||||
* This could be different from `action`: e.g., `tool_call` may contain
|
||||
* `security_risk` field predicted by LLM when LLM risk analyzer is enabled,
|
||||
* while `action` does not.
|
||||
*/
|
||||
tool_call: ChatCompletionMessageToolCall;
|
||||
|
||||
/**
|
||||
* Groups related actions from same LLM response. This helps in tracking
|
||||
* and managing results of parallel function calling from the same LLM
|
||||
* response.
|
||||
*/
|
||||
llm_response_id: EventID;
|
||||
|
||||
/**
|
||||
* The LLM's assessment of the safety risk of this action
|
||||
*/
|
||||
security_risk: SecurityRisk;
|
||||
}
|
||||
46
frontend/src/types/v1/core/events/condensation-event.ts
Normal file
46
frontend/src/types/v1/core/events/condensation-event.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { EventID } from "../base/common";
|
||||
import { BaseEvent } from "../base/event";
|
||||
|
||||
// Condensation event - indicates conversation history condensation is happening
|
||||
export interface CondensationEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for condensation events
|
||||
*/
|
||||
source: "environment";
|
||||
|
||||
/**
|
||||
* The IDs of the events that are being forgotten (removed from the View given to the LLM)
|
||||
*/
|
||||
forgotten_event_ids: EventID[];
|
||||
|
||||
/**
|
||||
* An optional summary of the events being forgotten
|
||||
*/
|
||||
summary?: string;
|
||||
|
||||
/**
|
||||
* An optional offset to the start of the resulting view indicating where the summary should be inserted
|
||||
*/
|
||||
summary_offset?: number;
|
||||
}
|
||||
|
||||
// Condensation request event - used to request a condensation of conversation history
|
||||
export interface CondensationRequestEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for condensation request events
|
||||
*/
|
||||
source: "environment";
|
||||
}
|
||||
|
||||
// Condensation summary event - represents a summary generated by a condenser
|
||||
export interface CondensationSummaryEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for condensation summary events
|
||||
*/
|
||||
source: "environment";
|
||||
|
||||
/**
|
||||
* The summary text
|
||||
*/
|
||||
summary: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BaseEvent } from "../base/event";
|
||||
|
||||
// Conversation state update event - contains conversation state updates
|
||||
export interface ConversationStateUpdateEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for conversation state update events
|
||||
*/
|
||||
source: "environment";
|
||||
|
||||
/**
|
||||
* Unique key for this state update event.
|
||||
* Can be "full_state" for full state snapshots or field names for partial updates.
|
||||
*/
|
||||
key: string;
|
||||
|
||||
/**
|
||||
* Serialized conversation state updates.
|
||||
* For "full_state" key, this contains the complete conversation state.
|
||||
* For field-specific keys, this contains the updated field value.
|
||||
*/
|
||||
value: unknown;
|
||||
}
|
||||
8
frontend/src/types/v1/core/events/index.ts
Normal file
8
frontend/src/types/v1/core/events/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Export all event types
|
||||
export * from "./action-event";
|
||||
export * from "./condensation-event";
|
||||
export * from "./conversation-state-event";
|
||||
export * from "./message-event";
|
||||
export * from "./observation-event";
|
||||
export * from "./pause-event";
|
||||
export * from "./system-event";
|
||||
19
frontend/src/types/v1/core/events/message-event.ts
Normal file
19
frontend/src/types/v1/core/events/message-event.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TextContent } from "../base/common";
|
||||
import { BaseEvent, Message } from "../base/event";
|
||||
|
||||
export interface MessageEvent extends BaseEvent {
|
||||
/**
|
||||
* The exact LLM message for this message event
|
||||
*/
|
||||
llm_message: Message;
|
||||
|
||||
/**
|
||||
* List of activated microagent names
|
||||
*/
|
||||
activated_microagents: string[];
|
||||
|
||||
/**
|
||||
* List of content added by agent context
|
||||
*/
|
||||
extended_content: TextContent[];
|
||||
}
|
||||
70
frontend/src/types/v1/core/events/observation-event.ts
Normal file
70
frontend/src/types/v1/core/events/observation-event.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { EventID, ToolCallID } from "../base/common";
|
||||
import { BaseEvent } from "../base/event";
|
||||
import { Observation } from "../base/observation";
|
||||
|
||||
// Base interface for observation events
|
||||
export interface ObservationBaseEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for observation events
|
||||
*/
|
||||
source: "environment";
|
||||
|
||||
/**
|
||||
* The tool name that this observation is responding to
|
||||
*/
|
||||
tool_name: string;
|
||||
|
||||
/**
|
||||
* The tool call id that this observation is responding to
|
||||
*/
|
||||
tool_call_id: ToolCallID;
|
||||
}
|
||||
|
||||
// Main observation event interface
|
||||
export interface ObservationEvent extends ObservationBaseEvent {
|
||||
/**
|
||||
* The observation (tool call) sent to LLM
|
||||
*/
|
||||
observation: Observation;
|
||||
|
||||
/**
|
||||
* The action id that this observation is responding to
|
||||
*/
|
||||
action_id: EventID;
|
||||
}
|
||||
|
||||
// User rejection observation event
|
||||
export interface UserRejectObservation extends ObservationBaseEvent {
|
||||
/**
|
||||
* Reason for rejecting the action
|
||||
*/
|
||||
rejection_reason: string;
|
||||
|
||||
/**
|
||||
* The action id that this observation is responding to
|
||||
*/
|
||||
action_id: EventID;
|
||||
}
|
||||
|
||||
// Agent error event
|
||||
export interface AgentErrorEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "agent" for agent error events
|
||||
*/
|
||||
source: "agent";
|
||||
|
||||
/**
|
||||
* The tool name that this observation is responding to
|
||||
*/
|
||||
tool_name: string;
|
||||
|
||||
/**
|
||||
* The tool call id that this observation is responding to
|
||||
*/
|
||||
tool_call_id: ToolCallID;
|
||||
|
||||
/**
|
||||
* The error message from the scaffold
|
||||
*/
|
||||
error: string;
|
||||
}
|
||||
9
frontend/src/types/v1/core/events/pause-event.ts
Normal file
9
frontend/src/types/v1/core/events/pause-event.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { BaseEvent } from "../base/event";
|
||||
|
||||
// Pause event - indicates that agent execution was paused by user request
|
||||
export interface PauseEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "user" for pause events
|
||||
*/
|
||||
source: "user";
|
||||
}
|
||||
20
frontend/src/types/v1/core/events/system-event.ts
Normal file
20
frontend/src/types/v1/core/events/system-event.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { TextContent } from "../base/common";
|
||||
import { BaseEvent, ChatCompletionToolParam } from "../base/event";
|
||||
|
||||
// System prompt event interface
|
||||
export interface SystemPromptEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "agent" for system prompt events
|
||||
*/
|
||||
source: "agent";
|
||||
|
||||
/**
|
||||
* The system prompt text
|
||||
*/
|
||||
system_prompt: TextContent;
|
||||
|
||||
/**
|
||||
* List of tools in OpenAI tool format
|
||||
*/
|
||||
tools: ChatCompletionToolParam[];
|
||||
}
|
||||
10
frontend/src/types/v1/core/index.ts
Normal file
10
frontend/src/types/v1/core/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Export all core types
|
||||
|
||||
// Base types (primitive types like Action, Observation, common interfaces)
|
||||
export * from "./base";
|
||||
|
||||
// Event types (main events that extend BaseEvent)
|
||||
export * from "./events";
|
||||
|
||||
// Union type for all OpenHands events
|
||||
export * from "./openhands-event";
|
||||
34
frontend/src/types/v1/core/openhands-event.ts
Normal file
34
frontend/src/types/v1/core/openhands-event.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Import all event types
|
||||
import {
|
||||
ActionEvent,
|
||||
MessageEvent,
|
||||
ObservationEvent,
|
||||
UserRejectObservation,
|
||||
AgentErrorEvent,
|
||||
SystemPromptEvent,
|
||||
CondensationEvent,
|
||||
CondensationRequestEvent,
|
||||
CondensationSummaryEvent,
|
||||
ConversationStateUpdateEvent,
|
||||
PauseEvent,
|
||||
} from "./events/index";
|
||||
|
||||
/**
|
||||
* Union type representing all possible OpenHands events.
|
||||
* This includes all main event types that can occur in the system.
|
||||
*/
|
||||
export type OpenHandsEvent =
|
||||
// Core action and observation events
|
||||
| ActionEvent
|
||||
| MessageEvent
|
||||
| ObservationEvent
|
||||
| UserRejectObservation
|
||||
| AgentErrorEvent
|
||||
| SystemPromptEvent
|
||||
// Conversation management events
|
||||
| CondensationEvent
|
||||
| CondensationRequestEvent
|
||||
| CondensationSummaryEvent
|
||||
| ConversationStateUpdateEvent
|
||||
// Control events
|
||||
| PauseEvent;
|
||||
@@ -104,19 +104,8 @@ export function getStatusCode(
|
||||
runtimeStatus: RuntimeStatus | null,
|
||||
agentState: AgentState | null,
|
||||
) {
|
||||
// Debug logging for status determination
|
||||
console.log('[STATUS_DEBUG] getStatusCode called:', {
|
||||
statusMessage,
|
||||
webSocketStatus,
|
||||
conversationStatus,
|
||||
runtimeStatus,
|
||||
agentState,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Handle conversation and runtime stopped states
|
||||
if (conversationStatus === "STOPPED" || runtimeStatus === "STATUS$STOPPED") {
|
||||
console.log('[STATUS_DEBUG] Returning STOPPED status');
|
||||
return I18nKey.CHAT_INTERFACE$STOPPED;
|
||||
}
|
||||
|
||||
@@ -145,24 +134,11 @@ export function getStatusCode(
|
||||
return runtimeStatus;
|
||||
}
|
||||
|
||||
// Handle conversation starting state BEFORE WebSocket states
|
||||
// This ensures users see "Initializing agent..." instead of "Connecting..."
|
||||
if (conversationStatus === "STARTING") {
|
||||
console.log('[STATUS_DEBUG] Conversation STARTING, showing initializing status');
|
||||
return I18nKey.AGENT_STATUS$INITIALIZING;
|
||||
}
|
||||
|
||||
// Handle WebSocket connection states
|
||||
if (webSocketStatus === "DISCONNECTED") {
|
||||
console.log('[STATUS_DEBUG] WebSocket DISCONNECTED, returning disconnected status');
|
||||
return I18nKey.CHAT_INTERFACE$DISCONNECTED;
|
||||
}
|
||||
if (webSocketStatus === "CONNECTING") {
|
||||
console.log('[STATUS_DEBUG] WebSocket CONNECTING - now only shown if conversation not STARTING', {
|
||||
conversationStatus,
|
||||
runtimeStatus,
|
||||
agentState
|
||||
});
|
||||
return I18nKey.CHAT_INTERFACE$CONNECTING;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SuggestedTaskGroup } from "#/utils/types";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -509,3 +511,50 @@ export const getStatusClassName = (status: string) => {
|
||||
}
|
||||
return "bg-gray-700 text-gray-300";
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to apply client-side filtering based on search query
|
||||
* @param repo The Git repository to check
|
||||
* @param searchQuery The search query string
|
||||
* @returns True if the repository should be included based on the search query
|
||||
*/
|
||||
export const shouldIncludeRepository = (
|
||||
repo: GitRepository,
|
||||
searchQuery: string,
|
||||
): boolean => {
|
||||
if (!searchQuery.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sanitizedQuery = sanitizeQuery(searchQuery);
|
||||
const sanitizedRepoName = sanitizeQuery(repo.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the OpenHands query string based on the provider
|
||||
* @param provider The git provider
|
||||
* @returns The query string for searching OpenHands repositories
|
||||
*/
|
||||
export const getOpenHandsQuery = (provider: Provider | null): string => {
|
||||
if (provider === "gitlab") {
|
||||
return "openhands-config";
|
||||
}
|
||||
return ".openhands";
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a repository has the OpenHands suffix based on the provider
|
||||
* @param repo The Git repository to check
|
||||
* @param provider The git provider
|
||||
* @returns True if the repository has the OpenHands suffix
|
||||
*/
|
||||
export const hasOpenHandsSuffix = (
|
||||
repo: GitRepository,
|
||||
provider: Provider | null,
|
||||
): boolean => {
|
||||
if (provider === "gitlab") {
|
||||
return repo.full_name.endsWith("/openhands-config");
|
||||
}
|
||||
return repo.full_name.endsWith("/.openhands");
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export const VERIFIED_MODELS = [
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
"gemini-2.5-pro",
|
||||
@@ -51,6 +52,7 @@ export const VERIFIED_ANTHROPIC_MODELS = [
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
];
|
||||
@@ -67,6 +69,7 @@ export const VERIFIED_MISTRAL_MODELS = [
|
||||
// (e.g., they return `claude-sonnet-4-20250514` instead of `openhands/claude-sonnet-4-20250514`)
|
||||
export const VERIFIED_OPENHANDS_MODELS = [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
"claude-opus-4-20250514",
|
||||
|
||||
@@ -28,6 +28,7 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
* Before implementing any changes, first thoroughly understand the codebase through exploration.
|
||||
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
|
||||
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
|
||||
* If working in a git repo, before you commit code create a .gitignore file if one doesn't exist. And if there are existing files that should not be included then update the .gitignore file as appropriate.
|
||||
</CODE_QUALITY>
|
||||
|
||||
<VERSION_CONTROL>
|
||||
|
||||
@@ -165,6 +165,7 @@ VERIFIED_OPENAI_MODELS = [
|
||||
|
||||
VERIFIED_ANTHROPIC_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
'claude-3-7-sonnet-20250219',
|
||||
@@ -186,6 +187,7 @@ VERIFIED_MISTRAL_MODELS = [
|
||||
|
||||
VERIFIED_OPENHANDS_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'gpt-5-2025-08-07',
|
||||
'gpt-5-mini-2025-08-07',
|
||||
'claude-opus-4-20250514',
|
||||
|
||||
@@ -214,7 +214,7 @@ class GitHubReposMixin(GitHubMixinBase):
|
||||
all_repos = []
|
||||
|
||||
# Search in user repositories
|
||||
user_query = f'{query} user:{user.login}'
|
||||
user_query = f'in:name {query} user:{user.login}'
|
||||
user_params = params.copy()
|
||||
user_params['q'] = user_query
|
||||
|
||||
|
||||
@@ -174,17 +174,7 @@ class ProviderHandler:
|
||||
) -> SecretStr | None:
|
||||
"""Get latest token from service"""
|
||||
try:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Attempting to fetch latest {provider} token from '
|
||||
f'{self.REFRESH_TOKEN_URL} for session {self.sid}'
|
||||
)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Using session API key: '
|
||||
f'{self.session_api_key[:10] if self.session_api_key else "None"}..., '
|
||||
f'provider={provider.value}, sid={self.sid}'
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
self.REFRESH_TOKEN_URL,
|
||||
headers={
|
||||
@@ -193,72 +183,13 @@ class ProviderHandler:
|
||||
params={'provider': provider.value, 'sid': self.sid},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Response status: {resp.status_code} for {provider}'
|
||||
)
|
||||
|
||||
# Log response headers for debugging
|
||||
logger.info(f'[TOKEN_DEBUG] Response headers: {dict(resp.headers)}')
|
||||
|
||||
# Check for redirect (expired Keycloak session)
|
||||
if resp.status_code == 302:
|
||||
redirect_url = resp.headers.get('Location', 'Unknown')
|
||||
# Check if this is OAuth2 proxy CSRF issue vs actual token expiry
|
||||
is_csrf_issue = '_oauth2_proxy_csrf' in resp.headers.get(
|
||||
'set-cookie', ''
|
||||
)
|
||||
logger.error(
|
||||
f'[TOKEN_DEBUG] Got 302 redirect for {provider} token refresh. '
|
||||
f'{"OAuth2 Proxy CSRF validation failed" if is_csrf_issue else "Keycloak session expired"}. '
|
||||
f'Redirect URL: {redirect_url[:200]}... '
|
||||
f'User needs to re-authenticate.'
|
||||
)
|
||||
# Log OAuth2 proxy cookie details
|
||||
set_cookie = resp.headers.get('set-cookie', 'N/A')
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] OAuth2 proxy CSRF cookie in redirect: {set_cookie[:150]}...'
|
||||
)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] This appears to be {"a CSRF validation issue (pod changed?)" if is_csrf_issue else "a token expiry issue"}'
|
||||
)
|
||||
# Don't try to parse JSON from a redirect response
|
||||
return None
|
||||
|
||||
# Check for forbidden (wrong session key)
|
||||
if resp.status_code == 403:
|
||||
logger.error(
|
||||
f'[TOKEN_DEBUG] Got 403 Forbidden for {provider} token refresh. '
|
||||
f'Session key mismatch or invalid. Session API key: '
|
||||
f'{self.session_api_key[:10] if self.session_api_key else "None"}... '
|
||||
f'Response body: {resp.text[:200]}'
|
||||
)
|
||||
return None
|
||||
|
||||
# Check for unauthorized
|
||||
if resp.status_code == 401:
|
||||
logger.error(
|
||||
f'[TOKEN_DEBUG] Got 401 Unauthorized for {provider} token refresh. '
|
||||
f'Authentication failed. Response body: {resp.text[:200]}'
|
||||
)
|
||||
return None
|
||||
|
||||
resp.raise_for_status()
|
||||
data = TokenResponse.model_validate_json(resp.text)
|
||||
|
||||
# Log token info (safely)
|
||||
token_str = data.token
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Successfully fetched {provider} token. '
|
||||
f'Token prefix: {token_str[:10] if len(token_str) > 10 else "SHORT"}, '
|
||||
f'Length: {len(token_str)}'
|
||||
)
|
||||
|
||||
return SecretStr(data.token)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[TOKEN_DEBUG] Failed to fetch latest token for provider {provider}: '
|
||||
f'{type(e).__name__}: {e}',
|
||||
f'Failed to fetch latest token for provider {provider}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@@ -535,10 +466,22 @@ class ProviderHandler:
|
||||
except Exception as e:
|
||||
errors.append(f'{provider.value}: {str(e)}')
|
||||
|
||||
# Log all accumulated errors before raising AuthenticationError
|
||||
logger.error(
|
||||
f'Failed to access repository {repository} with all available providers. Errors: {"; ".join(errors)}'
|
||||
)
|
||||
# Log detailed error based on whether we had tokens or not
|
||||
if not self.provider_tokens:
|
||||
logger.error(
|
||||
f'Failed to access repository {repository}: No provider tokens available. '
|
||||
f'provider_tokens dict is empty.'
|
||||
)
|
||||
elif errors:
|
||||
logger.error(
|
||||
f'Failed to access repository {repository} with all available providers. '
|
||||
f'Tried providers: {list(self.provider_tokens.keys())}. '
|
||||
f'Errors: {"; ".join(errors)}'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'Failed to access repository {repository}: Unknown error (no providers tried, no errors recorded)'
|
||||
)
|
||||
raise AuthenticationError(f'Unable to access repo {repository}')
|
||||
|
||||
async def get_branches(
|
||||
|
||||
@@ -148,7 +148,10 @@ class LLM(RetryMixin, DebugMixin):
|
||||
logger.debug(
|
||||
f'Gemini model {self.config.model} with reasoning_effort {self.config.reasoning_effort} mapped to thinking {kwargs.get("thinking")}'
|
||||
)
|
||||
|
||||
elif 'claude-sonnet-4-5' in self.config.model:
|
||||
kwargs.pop(
|
||||
'reasoning_effort', None
|
||||
) # don't send reasoning_effort to Claude Sonnet 4.5
|
||||
else:
|
||||
kwargs['reasoning_effort'] = self.config.reasoning_effort
|
||||
kwargs.pop(
|
||||
@@ -507,6 +510,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
'claude-3-7-sonnet',
|
||||
'claude-3.7-sonnet',
|
||||
'claude-sonnet-4',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
]
|
||||
if any(model in self.config.model for model in sonnet_models):
|
||||
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
|
||||
@@ -817,6 +821,8 @@ class LLM(RetryMixin, DebugMixin):
|
||||
message.force_string_serializer = True
|
||||
if 'openrouter/anthropic/claude-sonnet-4' in self.config.model:
|
||||
message.force_string_serializer = True
|
||||
if 'openrouter/anthropic/claude-sonnet-4-5-20250929' in self.config.model:
|
||||
message.force_string_serializer = True
|
||||
|
||||
# let pydantic handle the serialization
|
||||
return [message.model_dump() for message in messages]
|
||||
|
||||
@@ -65,6 +65,7 @@ FUNCTION_CALLING_PATTERNS: list[str] = [
|
||||
'claude-3.7-sonnet*',
|
||||
'claude-sonnet-3-7-latest',
|
||||
'claude-3-5-sonnet*',
|
||||
'claude-3.5-sonnet*', # Accept dot-notation for Sonnet 3.5 as well
|
||||
'claude-3.5-haiku*',
|
||||
'claude-3-5-haiku*',
|
||||
'claude-sonnet-4*',
|
||||
@@ -102,6 +103,7 @@ REASONING_EFFORT_PATTERNS: list[str] = [
|
||||
'gpt-5*',
|
||||
# DeepSeek reasoning family
|
||||
'deepseek-r1-0528*',
|
||||
'claude-sonnet-4-5*',
|
||||
]
|
||||
|
||||
PROMPT_CACHE_PATTERNS: list[str] = [
|
||||
|
||||
@@ -209,26 +209,15 @@ class Runtime(FileEditRuntimeMixin):
|
||||
return self._runtime_initialized
|
||||
|
||||
def setup_initial_env(self) -> None:
|
||||
logger.debug(
|
||||
f'[ENV_SETUP_DEBUG] setup_initial_env called with attach_to_existing={self.attach_to_existing}'
|
||||
)
|
||||
if self.attach_to_existing:
|
||||
logger.debug(
|
||||
'[ENV_SETUP_DEBUG] Skipping environment setup - attach_to_existing is True'
|
||||
)
|
||||
return
|
||||
logger.debug(
|
||||
'[ENV_SETUP_DEBUG] Performing full environment setup - attach_to_existing is False'
|
||||
)
|
||||
logger.debug(f'Adding env vars: {self.initial_env_vars.keys()}')
|
||||
self.add_env_vars(self.initial_env_vars)
|
||||
if self.config.sandbox.runtime_startup_env_vars:
|
||||
self.add_env_vars(self.config.sandbox.runtime_startup_env_vars)
|
||||
|
||||
# Configure git settings
|
||||
logger.debug('[ENV_SETUP_DEBUG] Configuring git settings')
|
||||
self._setup_git_config()
|
||||
logger.debug('[ENV_SETUP_DEBUG] Environment setup complete')
|
||||
|
||||
def close(self) -> None:
|
||||
"""This should only be called by conversation manager or closing the session.
|
||||
|
||||
@@ -129,11 +129,15 @@ class ActionExecutionClient(Runtime):
|
||||
return send_request(self.session, method, url, **kwargs)
|
||||
|
||||
def check_if_alive(self) -> None:
|
||||
request_url = f'{self.action_execution_server_url}/alive'
|
||||
self.log('debug', f'Sending request to: {request_url}')
|
||||
response = self._send_action_server_request(
|
||||
'GET',
|
||||
f'{self.action_execution_server_url}/alive',
|
||||
request_url,
|
||||
timeout=5,
|
||||
)
|
||||
self.log('debug', f'Response status code: {response.status_code}')
|
||||
self.log('debug', f'Response text: {response.text}')
|
||||
assert response.is_closed
|
||||
|
||||
def list_files(self, path: str | None = None) -> list[str]:
|
||||
|
||||
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
|
||||
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
|
||||
```toml
|
||||
[sandbox]
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik"
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
@@ -77,14 +77,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
git_provider_tokens,
|
||||
)
|
||||
logger.debug(f'RemoteRuntime.init user_id {user_id}')
|
||||
# Debug logging for initialization parameters
|
||||
self.log(
|
||||
'info',
|
||||
f'[TOKEN_DEBUG] RemoteRuntime.__init__ called with: '
|
||||
f'sid={sid}, attach_to_existing={attach_to_existing}, '
|
||||
f'has_tokens={git_provider_tokens is not None}, '
|
||||
f'user_id={user_id}',
|
||||
)
|
||||
if self.config.sandbox.api_key is None:
|
||||
raise ValueError(
|
||||
'API key is required to use the remote runtime. '
|
||||
@@ -142,11 +134,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
|
||||
def _start_or_attach_to_runtime(self) -> None:
|
||||
self.log('info', 'Starting or attaching to runtime')
|
||||
self.log(
|
||||
'info',
|
||||
f'[TOKEN_DEBUG] _start_or_attach_to_runtime: attach_to_existing={self.attach_to_existing}, '
|
||||
f'has_tokens={self.git_provider_tokens is not None}',
|
||||
)
|
||||
existing_runtime = self._check_existing_runtime()
|
||||
if existing_runtime:
|
||||
self.log('info', f'Using existing runtime with ID: {self.runtime_id}')
|
||||
@@ -176,38 +163,15 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
assert self.runtime_url is not None, (
|
||||
'Runtime URL is not set. This should never happen.'
|
||||
)
|
||||
# Log initialization path differences
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', 'Waiting for runtime to be alive...')
|
||||
self.log(
|
||||
'info',
|
||||
'[INIT_PATH_DEBUG] Following NEW runtime path: will wait for runtime and show "Runtime is ready"',
|
||||
)
|
||||
else:
|
||||
self.log(
|
||||
'info',
|
||||
'[INIT_PATH_DEBUG] Following ATTACH path: skipping "Waiting" message and "Runtime is ready" message',
|
||||
)
|
||||
|
||||
self._wait_until_alive()
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', 'Runtime is ready.')
|
||||
self.log('info', '[INIT_PATH_DEBUG] Completed NEW runtime initialization')
|
||||
else:
|
||||
self.log(
|
||||
'info',
|
||||
'[INIT_PATH_DEBUG] Completed ATTACH runtime initialization (minimal setup)',
|
||||
)
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
|
||||
def _check_existing_runtime(self) -> bool:
|
||||
self.log('info', f'Checking for existing runtime with session ID: {self.sid}')
|
||||
self.log(
|
||||
'info',
|
||||
f'[TOKEN_DEBUG] _check_existing_runtime: attach_to_existing={self.attach_to_existing}, '
|
||||
f'has_tokens={self.git_provider_tokens is not None}',
|
||||
)
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
@@ -216,11 +180,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
data = response.json()
|
||||
status = data.get('status')
|
||||
self.log('info', f'Found runtime with status: {status}')
|
||||
self.log(
|
||||
'info',
|
||||
f'[TOKEN_DEBUG] Runtime response data: runtime_id={data.get("runtime_id")}, '
|
||||
f'status={status}, session_id={data.get("session_id")}',
|
||||
)
|
||||
if status == 'running' or status == 'paused':
|
||||
self._parse_runtime_response(response)
|
||||
except httpx.HTTPError as e:
|
||||
@@ -240,37 +199,22 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
|
||||
if status == 'running':
|
||||
self.log('info', 'Found existing runtime in running state')
|
||||
self.log(
|
||||
'info', '[STATE_TRANSITION] Runtime state: running → (no change needed)'
|
||||
)
|
||||
return True
|
||||
elif status == 'stopped':
|
||||
self.log('info', 'Found existing runtime, but it is stopped')
|
||||
self.log(
|
||||
'info', '[STATE_TRANSITION] Runtime state: stopped → (cannot resume)'
|
||||
)
|
||||
return False
|
||||
elif status == 'paused':
|
||||
self.log(
|
||||
'info', 'Found existing runtime in paused state, attempting to resume'
|
||||
)
|
||||
self.log('info', '[STATE_TRANSITION] Runtime state: paused → resuming')
|
||||
try:
|
||||
self._resume_runtime()
|
||||
self.log('info', 'Successfully resumed paused runtime')
|
||||
self.log(
|
||||
'info',
|
||||
'[STATE_TRANSITION] Runtime state: paused → running (resume successful)',
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error', f'Failed to resume paused runtime: {e}', exc_info=True
|
||||
)
|
||||
self.log(
|
||||
'error',
|
||||
'[STATE_TRANSITION] Runtime state: paused → failed (resume failed)',
|
||||
)
|
||||
# Return false to indicate we couldn't use the existing runtime
|
||||
return False
|
||||
else:
|
||||
@@ -369,21 +313,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
4. Update env vars
|
||||
"""
|
||||
self.log('info', f'Attempting to resume runtime with ID: {self.runtime_id}')
|
||||
# Debug logging for token refresh investigation
|
||||
self.log(
|
||||
'info',
|
||||
f'[TOKEN_DEBUG] Starting resume process for runtime {self.runtime_id}',
|
||||
)
|
||||
self.log(
|
||||
'info',
|
||||
f'[TOKEN_DEBUG] attach_to_existing={self.attach_to_existing}, '
|
||||
f'has git_provider_tokens={self.git_provider_tokens is not None}',
|
||||
)
|
||||
self.log(
|
||||
'info',
|
||||
f'[RESUME_PATH_DEBUG] _resume_runtime called with attach_to_existing={self.attach_to_existing}. '
|
||||
f'This is problematic if False - we are resuming but not attaching!',
|
||||
)
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
@@ -414,11 +343,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
raise
|
||||
|
||||
try:
|
||||
self.log(
|
||||
'info',
|
||||
'[ENV_SETUP_DEBUG] Calling setup_initial_env() after resume. '
|
||||
f'attach_to_existing={self.attach_to_existing} - if False, this might re-initialize things!',
|
||||
)
|
||||
self.setup_initial_env()
|
||||
self.log('info', 'Successfully set up initial environment after resume')
|
||||
except Exception as e:
|
||||
@@ -430,10 +354,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
raise
|
||||
|
||||
self.log('info', 'Runtime successfully resumed and alive.')
|
||||
self.log(
|
||||
'info',
|
||||
f'[RESUME_COMPLETE_DEBUG] Resume completed with attach_to_existing={self.attach_to_existing}',
|
||||
)
|
||||
|
||||
def _parse_runtime_response(self, response: httpx.Response) -> None:
|
||||
start_response = response.json()
|
||||
@@ -495,11 +415,19 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
|
||||
def _wait_until_alive_impl(self) -> None:
|
||||
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
|
||||
self.log(
|
||||
'debug',
|
||||
f'Sending request to: {self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
|
||||
)
|
||||
runtime_info_response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
|
||||
)
|
||||
runtime_data = runtime_info_response.json()
|
||||
self.log(
|
||||
'debug',
|
||||
f'received response: {runtime_data}',
|
||||
)
|
||||
assert 'runtime_id' in runtime_data
|
||||
assert runtime_data['runtime_id'] == self.runtime_id
|
||||
assert 'pod_status' in runtime_data
|
||||
|
||||
@@ -13,6 +13,15 @@ from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
|
||||
'1',
|
||||
'true',
|
||||
't',
|
||||
'yes',
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JupyterRequirement(PluginRequirement):
|
||||
@@ -36,7 +45,7 @@ class JupyterPlugin(Plugin):
|
||||
|
||||
if not is_local_runtime:
|
||||
# Non-LocalRuntime
|
||||
prefix = f'su - {username} -s '
|
||||
prefix = f'su - {username} -s ' if SU_TO_USER else ''
|
||||
# cd to code repo, setup all env vars and run micromamba
|
||||
poetry_prefix = (
|
||||
'cd /openhands/code\n'
|
||||
|
||||
@@ -15,6 +15,16 @@ from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.utils.system import check_port_available
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
|
||||
'1',
|
||||
'true',
|
||||
't',
|
||||
'yes',
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VSCodeRequirement(PluginRequirement):
|
||||
@@ -37,7 +47,7 @@ class VSCodePlugin(Plugin):
|
||||
)
|
||||
return
|
||||
|
||||
if username not in ['root', 'openhands']:
|
||||
if username not in filter(None, [RUNTIME_USERNAME, 'root', 'openhands']):
|
||||
self.vscode_port = None
|
||||
self.vscode_connection_token = None
|
||||
logger.warning(
|
||||
@@ -83,13 +93,19 @@ class VSCodePlugin(Plugin):
|
||||
if path_mode:
|
||||
base_path_flag = f' --server-base-path /{runtime_id}/vscode'
|
||||
|
||||
cmd = (
|
||||
f"su - {username} -s /bin/bash << 'EOF'\n"
|
||||
f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
|
||||
f'cd {workspace_path}\n'
|
||||
f'exec /openhands/.openvscode-server/bin/openvscode-server --host 0.0.0.0 --connection-token {self.vscode_connection_token} --port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
|
||||
'EOF'
|
||||
)
|
||||
cmd = (
|
||||
(
|
||||
f"su - {username} -s /bin/bash << 'EOF'\n"
|
||||
if SU_TO_USER
|
||||
else "/bin/bash << 'EOF'\n"
|
||||
)
|
||||
+ f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
|
||||
+ f'cd {workspace_path}\n'
|
||||
+ 'exec /openhands/.openvscode-server/bin/openvscode-server '
|
||||
+ f'--host 0.0.0.0 --connection-token {self.vscode_connection_token} '
|
||||
+ f'--port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
|
||||
+ 'EOF'
|
||||
)
|
||||
|
||||
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
|
||||
# to avoid ASYNC101 linting error
|
||||
|
||||
@@ -20,6 +20,16 @@ from openhands.events.observation.commands import (
|
||||
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
|
||||
'1',
|
||||
'true',
|
||||
't',
|
||||
'yes',
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
|
||||
def split_bash_commands(commands: str) -> list[str]:
|
||||
if not commands.strip():
|
||||
@@ -193,7 +203,9 @@ class BashSession:
|
||||
def initialize(self) -> None:
|
||||
self.server = libtmux.Server()
|
||||
_shell_command = '/bin/bash'
|
||||
if self.username in ['root', 'openhands']:
|
||||
if SU_TO_USER and self.username in list(
|
||||
filter(None, [RUNTIME_USERNAME, 'root', 'openhands'])
|
||||
):
|
||||
# This starts a non-login (new) shell for the given user
|
||||
_shell_command = f'su {self.username} -'
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
@@ -12,6 +14,9 @@ DEFAULT_PYTHON_PREFIX = [
|
||||
]
|
||||
DEFAULT_MAIN_MODULE = 'openhands.runtime.action_execution_server'
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
RUNTIME_UID = os.getenv('RUNTIME_UID')
|
||||
|
||||
|
||||
def get_action_execution_server_startup_command(
|
||||
server_port: int,
|
||||
@@ -26,7 +31,10 @@ def get_action_execution_server_startup_command(
|
||||
sandbox_config = app_config.sandbox
|
||||
logger.debug(f'app_config {vars(app_config)}')
|
||||
logger.debug(f'sandbox_config {vars(sandbox_config)}')
|
||||
logger.debug(f'override_user_id {override_user_id}')
|
||||
logger.debug(f'RUNTIME_USERNAME {RUNTIME_USERNAME}, RUNTIME_UID {RUNTIME_UID}')
|
||||
logger.debug(
|
||||
f'override_username {override_username}, override_user_id {override_user_id}'
|
||||
)
|
||||
|
||||
# Plugin args
|
||||
plugin_args = []
|
||||
@@ -40,10 +48,15 @@ def get_action_execution_server_startup_command(
|
||||
'--browsergym-eval-env'
|
||||
] + sandbox_config.browsergym_eval_env.split(' ')
|
||||
|
||||
username = override_username or (
|
||||
'openhands' if app_config.run_as_openhands else 'root'
|
||||
username = (
|
||||
override_username
|
||||
or RUNTIME_USERNAME
|
||||
or ('openhands' if app_config.run_as_openhands else 'root')
|
||||
)
|
||||
user_id = override_user_id or (1000 if app_config.run_as_openhands else 0)
|
||||
user_id = (
|
||||
override_user_id or RUNTIME_UID or (1000 if app_config.run_as_openhands else 0)
|
||||
)
|
||||
logger.debug(f'username {username}, user_id {user_id}')
|
||||
|
||||
base_cmd = [
|
||||
*python_prefix,
|
||||
|
||||
@@ -89,9 +89,9 @@ def get_runtime_image_repo_and_tag(base_image: str) -> tuple[str, str]:
|
||||
# Hash the repo if it's too long
|
||||
if len(repo) > 32:
|
||||
repo_hash = hashlib.md5(repo[:-24].encode()).hexdigest()[:8]
|
||||
|
||||
repo = f'{repo_hash}_{repo[-24:]}' # Use 8 char hash + last 24 chars
|
||||
else:
|
||||
repo = repo.replace('/', '_s_')
|
||||
repo = repo.replace('/', '_s_')
|
||||
|
||||
new_tag = f'oh_v{oh_version}_image_{repo}_tag_{tag}'
|
||||
|
||||
|
||||
@@ -24,14 +24,10 @@ RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl ca-certificates sudo apt-utils git jq tmux build-essential ripgrep ffmpeg \
|
||||
coreutils util-linux procps findutils grep sed \
|
||||
{%- if (base_image.endswith(':latest') or base_image.endswith(':24.04') or ('mswebench' in base_image)) -%}
|
||||
libgl1 \
|
||||
{%- else %}
|
||||
libgl1-mesa-glx \
|
||||
{% endif -%}
|
||||
libasound2-plugins libatomic1 \
|
||||
libasound2-plugins libatomic1 && \
|
||||
(apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \
|
||||
# Install Docker dependencies
|
||||
apt-transport-https ca-certificates curl gnupg lsb-release && \
|
||||
apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
TZ=Etc/UTC DEBIAN_FRONTEND=noninteractive \
|
||||
{%- if ('mswebench' in base_image) -%}
|
||||
@@ -46,10 +42,10 @@ RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl ca-certificates sudo apt-utils git jq tmux build-essential ripgrep ffmpeg \
|
||||
coreutils util-linux procps findutils grep sed \
|
||||
libgl1-mesa-glx \
|
||||
libasound2-plugins libatomic1 \
|
||||
libasound2-plugins libatomic1 && \
|
||||
(apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \
|
||||
# Install Docker dependencies
|
||||
apt-transport-https ca-certificates curl gnupg lsb-release
|
||||
apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release
|
||||
{% endif %}
|
||||
|
||||
{% if (('ubuntu' in base_image) or ('mswebench' in base_image)) %}
|
||||
|
||||
@@ -78,16 +78,8 @@ class ConversationManager(ABC):
|
||||
|
||||
async def is_agent_loop_running(self, sid: str) -> bool:
|
||||
"""Check if an agent loop is running for the given session ID."""
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
sids = await self.get_running_agent_loops(filter_to_sids={sid})
|
||||
is_running = bool(sids)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] is_agent_loop_running check: '
|
||||
f'sid={sid}, result={is_running}, '
|
||||
f'found_sids={sids}'
|
||||
)
|
||||
return is_running
|
||||
return bool(sids)
|
||||
|
||||
@abstractmethod
|
||||
async def get_running_agent_loops(
|
||||
|
||||
@@ -159,12 +159,6 @@ class StandaloneConversationManager(ConversationManager):
|
||||
f'join_conversation:{sid}:{connection_id}',
|
||||
extra={'session_id': sid, 'user_id': user_id},
|
||||
)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] ConversationManager.join_conversation: '
|
||||
f'sid={sid}, connection_id={connection_id}, '
|
||||
f'has_settings={settings is not None}, '
|
||||
f'SOURCE=conversation_manager (entry point for joins)'
|
||||
)
|
||||
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
|
||||
self._local_connection_id_to_session_id[connection_id] = sid
|
||||
agent_loop_info = await self.maybe_start_agent_loop(sid, settings, user_id)
|
||||
@@ -257,13 +251,6 @@ class StandaloneConversationManager(ConversationManager):
|
||||
# Get all items and convert to list for sorting
|
||||
items: Iterable[tuple[str, Session]] = self._local_agent_loops_by_sid.items()
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Standalone.get_running_agent_loops: '
|
||||
f'found {len(self._local_agent_loops_by_sid)} sessions, '
|
||||
f'filter_to_sids={filter_to_sids}, '
|
||||
f'session_ids={list(self._local_agent_loops_by_sid.keys())}'
|
||||
)
|
||||
|
||||
# Filter items if needed
|
||||
if filter_to_sids is not None:
|
||||
items = (item for item in items if item[0] in filter_to_sids)
|
||||
@@ -299,25 +286,11 @@ class StandaloneConversationManager(ConversationManager):
|
||||
replay_json: str | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
logger.info(f'maybe_start_agent_loop:{sid}', extra={'session_id': sid})
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] StandaloneConversationManager.maybe_start_agent_loop ENTRY: '
|
||||
f'sid={sid}, user_id={user_id}'
|
||||
)
|
||||
session = self._local_agent_loops_by_sid.get(sid)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] maybe_start_agent_loop: '
|
||||
f'sid={sid}, session_exists={session is not None}, '
|
||||
f'will_start_new={session is None}'
|
||||
)
|
||||
if not session:
|
||||
logger.info(f'[TOKEN_DEBUG] Starting NEW agent loop for sid={sid}')
|
||||
session = await self._start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] Using EXISTING agent loop for sid={sid} - THIS IS RESUME!'
|
||||
)
|
||||
return self._agent_loop_info_from_session(session)
|
||||
|
||||
async def _start_agent_loop(
|
||||
@@ -329,14 +302,6 @@ class StandaloneConversationManager(ConversationManager):
|
||||
replay_json: str | None = None,
|
||||
) -> Session:
|
||||
logger.info(f'starting_agent_loop:{sid}', extra={'session_id': sid})
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] StandaloneConversationManager._start_agent_loop CALLED: '
|
||||
f'sid={sid}, user_id={user_id}, '
|
||||
f'has_settings={settings is not None}, '
|
||||
f'has_initial_msg={initial_user_msg is not None}, '
|
||||
f'has_replay={replay_json is not None}, '
|
||||
f'SOURCE=standalone._start_agent_loop'
|
||||
)
|
||||
|
||||
response_ids = await self.get_running_agent_loops(user_id)
|
||||
if len(response_ids) >= self.config.max_concurrent_conversations:
|
||||
|
||||
@@ -49,11 +49,6 @@ async def connect(connection_id: str, environ: dict) -> None:
|
||||
logger.info(
|
||||
f'Socket request for conversation {conversation_id} with connection_id {connection_id}'
|
||||
)
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] SocketIO connect: conversation_id={conversation_id}, '
|
||||
f'connection_id={connection_id}, latest_event_id={latest_event_id}, '
|
||||
f'SOURCE=listen_socket.py (SocketIO entry point)'
|
||||
)
|
||||
raw_list = query_params.get('providers_set', [])
|
||||
providers_list = []
|
||||
for item in raw_list:
|
||||
@@ -126,11 +121,6 @@ async def connect(connection_id: str, environ: dict) -> None:
|
||||
user_id, conversation_id, providers_set
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[TOKEN_DEBUG] About to join conversation: conversation_id={conversation_id}, '
|
||||
f'has_conversation_init_data={conversation_init_data is not None}'
|
||||
)
|
||||
|
||||
agent_loop_info = await conversation_manager.join_conversation(
|
||||
conversation_id,
|
||||
connection_id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user