mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 985df5e24c | |||
| 2bbc37d552 |
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install poetry via pipx
|
||||
uses: abatilo/actions-poetry@v4
|
||||
uses: abatilo/actions-poetry@v3
|
||||
with:
|
||||
poetry-version: 2.1.3
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: poetry install --with dev,test,runtime
|
||||
- name: Run Windows unit tests
|
||||
run: poetry run pytest -svv tests/unit/runtime/utils/test_windows_bash.py
|
||||
run: poetry run pytest -svv tests/unit/test_windows_bash.py
|
||||
env:
|
||||
PYTHONPATH: ".;$env:PYTHONPATH"
|
||||
DEBUG: "1"
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Welcome Good First Issue
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment-on-good-first-issue:
|
||||
if: github.event.label.name == 'good first issue'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if welcome comment already exists
|
||||
id: check_comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const issueNumber = context.issue.number;
|
||||
const comments = await github.rest.issues.listComments({
|
||||
...context.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
const alreadyCommented = comments.data.some(
|
||||
(comment) =>
|
||||
comment.body.includes('<!-- auto-comment:good-first-issue -->')
|
||||
);
|
||||
|
||||
return alreadyCommented ? 'true' : 'false';
|
||||
|
||||
- name: Leave welcome comment
|
||||
if: steps.check_comment.outputs.result == 'false'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: "🙌 **Hey there, future contributor!** 🙌\n\n" +
|
||||
"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" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
@@ -144,35 +144,6 @@ Your specialized knowledge and instructions here...
|
||||
- Add the setting to the `Settings` model in `openhands/storage/data_models/settings.py`
|
||||
- Update any relevant backend code to apply the setting (e.g., in session creation)
|
||||
|
||||
#### Settings UI Patterns:
|
||||
|
||||
There are two main patterns for saving settings in the OpenHands frontend:
|
||||
|
||||
**Pattern 1: Entity-based Resources (Immediate Save)**
|
||||
- Used for: API Keys, Secrets, MCP Servers
|
||||
- Behavior: Changes are saved immediately when user performs actions (add/edit/delete)
|
||||
- Implementation:
|
||||
- No "Save Changes" button
|
||||
- No local state management or `isDirty` tracking
|
||||
- Uses dedicated mutation hooks for each operation (e.g., `use-add-mcp-server.ts`, `use-delete-mcp-server.ts`)
|
||||
- Each mutation triggers immediate API call with query invalidation for UI updates
|
||||
- Example: MCP settings, API Keys & Secrets tabs
|
||||
- Benefits: Simpler UX, no risk of losing changes, consistent with modern web app patterns
|
||||
|
||||
**Pattern 2: Form-based Settings (Manual Save)**
|
||||
- Used for: Application settings, LLM configuration
|
||||
- Behavior: Changes are accumulated locally and saved when user clicks "Save Changes"
|
||||
- Implementation:
|
||||
- Has "Save Changes" button that becomes enabled when changes are detected
|
||||
- Uses local state management with `isDirty` tracking
|
||||
- Uses `useSaveSettings` hook to save all changes at once
|
||||
- Example: LLM tab, Application tab
|
||||
- Benefits: Allows bulk changes, explicit save action, can validate all fields before saving
|
||||
|
||||
**When to use each pattern:**
|
||||
- Use Pattern 1 (Immediate Save) for entity management where each item is independent
|
||||
- Use Pattern 2 (Manual Save) for configuration forms where settings are interdependent or need validation
|
||||
|
||||
### Adding New LLM Models
|
||||
|
||||
To add a new LLM model to OpenHands, you need to update multiple files across both frontend and backend:
|
||||
|
||||
+1
-1
@@ -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.54-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.53-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
+3
-3
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -21,7 +21,7 @@ ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y curl make git build-essential jq gettext \
|
||||
&& apt-get install -y curl make git build-essential \
|
||||
&& python3 -m pip install poetry --break-system-packages
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.53-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.53-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:
|
||||
|
||||
+2082
-3928
File diff suppressed because it is too large
Load Diff
@@ -119,7 +119,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -128,8 +128,8 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -109,17 +109,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.53
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -130,28 +130,3 @@ docker run # ... \
|
||||
<Note>
|
||||
**Docker Desktop Required**: Network isolation features, including custom networks and `host.docker.internal` routing, require Docker Desktop. Docker Engine alone does not support these features on localhost across custom networks. If you're using Docker Engine without Docker Desktop, network isolation may not work as expected.
|
||||
</Note>
|
||||
|
||||
### Sidecar Containers
|
||||
|
||||
If you want to run sidecar containers to the sandbox 'runner' containers without exposing the sandbox containers to the host network, you can use the `SANDBOX_ADDITIONAL_NETWORKS` environment variable to specify additional Docker network names that should be added to the sandbox containers.
|
||||
|
||||
```bash
|
||||
docker network create openhands-sccache
|
||||
|
||||
docker run -d \
|
||||
--hostname openhandsredis \
|
||||
--network openhands-sccache \
|
||||
redis
|
||||
|
||||
docker run # ...
|
||||
-e SANDBOX_ADDITIONAL_NETWORKS='["openhands-sccache"]' \
|
||||
# ...
|
||||
```
|
||||
|
||||
Then all sandbox instances will have to access a shared redis instance at `openhandsredis:6379`.
|
||||
|
||||
#### Docker Compose gotcha
|
||||
|
||||
Note that Docker Compose adds a prefix (a scope) by default to created networks, which is not taken into account by the additional networks config. Therefore when using docker compose you have to either:
|
||||
- specify a network name via the `name` field to remove the scoping (https://docs.docker.com/reference/compose-file/networks/#name)
|
||||
- or provide the scope within the given config (e.g. `SANDBOX_ADDITIONAL_NETWORKS: '["myscope_openhands-sccache"]'` where `myscope` is the docker-compose assigned prefix).
|
||||
@@ -10,7 +10,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -147,7 +146,7 @@ def process_instance(
|
||||
|
||||
logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}')
|
||||
test_result = game.reward()
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
|
||||
@@ -18,7 +18,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -274,7 +273,7 @@ def process_instance(
|
||||
# remove when it becomes unnecessary
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -17,7 +17,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -247,7 +246,7 @@ def process_instance(
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -15,7 +15,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -295,7 +294,7 @@ def process_instance(
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
test_result = complete_runtime(runtime, instance)
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
|
||||
@@ -18,7 +18,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -423,7 +422,7 @@ def process_instance(
|
||||
# You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation.
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
|
||||
@@ -11,7 +11,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -89,7 +88,7 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
|
||||
@@ -16,7 +16,6 @@ from evaluation.utils.shared import (
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -481,7 +480,7 @@ def process_instance(
|
||||
|
||||
# NOTE: this is NO LONGER the event stream, but an agent history that includes delegate agent's events
|
||||
histories = [event_to_dict(event) for event in state.history]
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -17,7 +17,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -295,7 +294,7 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
test_result = complete_runtime(state)
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
|
||||
@@ -22,7 +22,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -270,7 +269,7 @@ Here is the task:
|
||||
'model_answer': model_answer,
|
||||
'ground_truth': instance['Final answer'],
|
||||
}
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
|
||||
@@ -12,7 +12,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -109,7 +108,7 @@ def process_instance(
|
||||
# attempt to parse model_answer
|
||||
ast_eval_fn = instance['ast_eval']
|
||||
correct, hallucination = ast_eval_fn(instance_id, model_answer_raw)
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
logger.info(
|
||||
f'Final message: {model_answer_raw} | Correctness: {correct} | Hallucination: {hallucination}'
|
||||
)
|
||||
|
||||
@@ -30,7 +30,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -293,7 +292,7 @@ Ok now its time to start solving the question. Good luck!
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -23,7 +23,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -249,7 +248,7 @@ def process_instance(
|
||||
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
test_result = complete_runtime(runtime, instance)
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
|
||||
@@ -22,7 +22,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -336,7 +335,7 @@ Be thorough in your exploration, testing, and reasoning. It's fine if your think
|
||||
)
|
||||
)
|
||||
assert state is not None
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else {}
|
||||
|
||||
test_result = complete_runtime(runtime, instance)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -248,7 +247,7 @@ def process_instance(
|
||||
)
|
||||
test_result['final_message'] = final_message
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
|
||||
@@ -13,7 +13,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -175,7 +174,7 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Instruction is the first message from the USER
|
||||
instruction = ''
|
||||
|
||||
@@ -15,7 +15,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -206,7 +205,7 @@ def process_instance(
|
||||
task_state = state.extra_data['task_state']
|
||||
logger.info('Task state: ' + str(task_state.to_dict()))
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
|
||||
@@ -26,7 +26,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -251,7 +250,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
)
|
||||
)
|
||||
assert state is not None
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else {}
|
||||
|
||||
test_result = complete_runtime(runtime)
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -219,7 +218,7 @@ If the program uses some packages that are incompatible, please figure out alter
|
||||
# You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation.
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
|
||||
@@ -93,9 +93,6 @@ export USE_HINT_TEXT=true # Ignore this if you are not sure.
|
||||
|
||||
# Specify a condenser configuration for memory management (default: NoOpCondenser)
|
||||
export EVAL_CONDENSER=summarizer_for_eval # Name of the condenser config group in config.toml
|
||||
|
||||
# Specify the instruction prompt template file name
|
||||
export INSTRUCTION_TEMPLATE_NAME=swe_custom.j2 # Name of the file in the swe_bench/prompts folder.
|
||||
```
|
||||
|
||||
Let's say you'd like to run 10 instances using `llm.eval_gpt4_1106_preview` and CodeActAgent,
|
||||
|
||||
@@ -108,9 +108,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata) -> MessageActio
|
||||
llm_model = metadata.llm_config.model
|
||||
|
||||
# Determine the template file based on mode and LLM
|
||||
if metadata.instruction_template_name:
|
||||
template_name = metadata.instruction_template_name
|
||||
elif mode.startswith('swt'):
|
||||
if mode.startswith('swt'):
|
||||
template_name = 'swt.j2'
|
||||
elif mode == 'swe':
|
||||
if 'gpt-4.1' in llm_model:
|
||||
@@ -124,7 +122,6 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata) -> MessageActio
|
||||
logger.error(f'Unexpected evaluation mode: {mode}. Falling back to default.')
|
||||
template_name = 'swe_default.j2'
|
||||
|
||||
logger.debug(f'Using instruction template file: {template_name}')
|
||||
# Set up Jinja2 environment
|
||||
# Assuming templates are in 'evaluation/benchmarks/swe_bench/prompts' relative to this script
|
||||
prompts_dir = os.path.join(os.path.dirname(__file__), 'prompts')
|
||||
|
||||
@@ -21,7 +21,6 @@ from evaluation.utils.shared import (
|
||||
EvalException,
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -180,7 +179,7 @@ def process_instance(
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
histories = [event_to_dict(event) for event in state.history]
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Save the output
|
||||
instruction = message_action.content
|
||||
|
||||
@@ -11,7 +11,6 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -135,7 +134,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
correct = eval_answer(str(model_answer_raw), str(answer))
|
||||
logger.info(f'Final message: {model_answer_raw} | Correctness: {correct}')
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
|
||||
@@ -12,7 +12,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -180,7 +179,7 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Instruction obtained from the first message from the USER
|
||||
instruction = ''
|
||||
|
||||
@@ -12,7 +12,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -164,7 +163,7 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Instruction is the first message from the USER
|
||||
instruction = ''
|
||||
|
||||
@@ -9,7 +9,6 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -136,7 +135,7 @@ def process_instance(
|
||||
assert len(histories) > 0, 'History should not be empty'
|
||||
|
||||
test_result: TestResult = test_class.verify_result(runtime, histories)
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
finally:
|
||||
runtime.close()
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ class EvalMetadata(BaseModel):
|
||||
data_split: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
condenser_config: CondenserConfig | None = None
|
||||
instruction_template_name: str | None = None
|
||||
|
||||
|
||||
class EvalOutput(BaseModel):
|
||||
@@ -206,7 +205,6 @@ def make_metadata(
|
||||
condenser_config=condenser_config
|
||||
if condenser_config
|
||||
else NoOpCondenserConfig(),
|
||||
instruction_template_name=os.environ.get('INSTRUCTION_TEMPLATE_NAME'),
|
||||
)
|
||||
metadata_json = metadata.model_dump_json()
|
||||
logger.info(f'Metadata: {metadata_json}')
|
||||
@@ -668,23 +666,8 @@ def is_fatal_runtime_error(error: str | None) -> bool:
|
||||
|
||||
|
||||
def get_metrics(state: State) -> dict[str, Any]:
|
||||
"""Extract metrics for evaluations.
|
||||
|
||||
Prefer ConversationStats (source of truth) and fall back to state.metrics for
|
||||
backward compatibility.
|
||||
"""
|
||||
metrics: dict[str, Any]
|
||||
try:
|
||||
if getattr(state, 'conversation_stats', None):
|
||||
combined = state.conversation_stats.get_combined_metrics()
|
||||
metrics = combined.get()
|
||||
elif getattr(state, 'metrics', None):
|
||||
metrics = state.metrics.get()
|
||||
else:
|
||||
metrics = {}
|
||||
except Exception:
|
||||
metrics = state.metrics.get() if getattr(state, 'metrics', None) else {}
|
||||
|
||||
"""Extract metrics from the state."""
|
||||
metrics = state.metrics.get() if state.metrics else {}
|
||||
metrics['condenser'] = get_condensation_metadata(state)
|
||||
return metrics
|
||||
|
||||
|
||||
@@ -232,16 +232,13 @@ describe("RepositorySelectionForm", () => {
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
"kubernetes/kubernetes",
|
||||
3,
|
||||
"github",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -271,16 +268,13 @@ describe("RepositorySelectionForm", () => {
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
"kubernetes/kubernetes",
|
||||
3,
|
||||
"github",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+12
-78
@@ -444,38 +444,28 @@ describe("MicroagentManagement", () => {
|
||||
expect(filePath2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render add microagent button", async () => {
|
||||
it("should display add microagent button in repository accordion", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("repository-name-tooltip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
expect(addButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should open modal when add button is clicked", async () => {
|
||||
it("should open add microagent modal when add button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("repository-name-tooltip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
await user.click(addButtons[0]);
|
||||
@@ -1302,18 +1292,11 @@ describe("MicroagentManagement", () => {
|
||||
it("should render add microagent button", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("repository-name-tooltip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
expect(addButtons.length).toBeGreaterThan(0);
|
||||
@@ -1323,18 +1306,11 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("repository-name-tooltip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
await user.click(addButtons[0]);
|
||||
@@ -1385,18 +1361,11 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("repository-name-tooltip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
await user.click(addButtons[0]);
|
||||
@@ -1416,18 +1385,11 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("repository-name-tooltip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
await user.click(addButtons[0]);
|
||||
@@ -1446,18 +1408,11 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("repository-name-tooltip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
await user.click(addButtons[0]);
|
||||
@@ -1486,18 +1441,11 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("repository-name-tooltip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
await user.click(addButtons[0]);
|
||||
@@ -1520,18 +1468,11 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("repository-name-tooltip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
await user.click(addButtons[0]);
|
||||
@@ -1553,18 +1494,11 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and processed
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for repositories to be displayed in the accordion
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("repository-name-tooltip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByTestId("add-microagent-button");
|
||||
await user.click(addButtons[0]);
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("Settings Screen", () => {
|
||||
"secrets",
|
||||
"api keys",
|
||||
];
|
||||
const sectionsToExclude = ["llm"];
|
||||
const sectionsToExclude = ["llm", "mcp"];
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.54.0",
|
||||
"version": "0.53.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.54.0",
|
||||
"version": "0.53.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.2",
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.54.0",
|
||||
"version": "0.53.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "../../hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "../../hooks/use-debounce";
|
||||
import OpenHands from "../../api/open-hands";
|
||||
import { GitRepository } from "../../types/git";
|
||||
import {
|
||||
@@ -21,6 +19,10 @@ export interface GitRepositoryDropdownProps {
|
||||
onChange?: (repository?: GitRepository) => void;
|
||||
}
|
||||
|
||||
interface SearchCache {
|
||||
[key: string]: GitRepository[];
|
||||
}
|
||||
|
||||
export function GitRepositoryDropdown({
|
||||
provider,
|
||||
value,
|
||||
@@ -31,20 +33,6 @@ export function GitRepositoryDropdown({
|
||||
onChange,
|
||||
}: GitRepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const debouncedSearchInput = useDebounce(searchInput, 300);
|
||||
|
||||
// Process search input to handle URLs
|
||||
const processedSearchInput = useMemo(() => {
|
||||
if (debouncedSearchInput.startsWith("https://")) {
|
||||
const match = debouncedSearchInput.match(
|
||||
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
|
||||
);
|
||||
return match ? match[1] : debouncedSearchInput;
|
||||
}
|
||||
return debouncedSearchInput;
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
@@ -57,10 +45,6 @@ export function GitRepositoryDropdown({
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
// Search query for processed input (handles URLs)
|
||||
const { data: searchData, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(processedSearchInput, provider);
|
||||
|
||||
const allOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
@@ -74,83 +58,75 @@ export function GitRepositoryDropdown({
|
||||
[data],
|
||||
);
|
||||
|
||||
const searchOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
searchData
|
||||
? searchData.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}))
|
||||
: [],
|
||||
[searchData],
|
||||
);
|
||||
// Keep track of search results
|
||||
const searchCache = useRef<SearchCache>({});
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
// First check in loaded pages
|
||||
const option = allOptions.find((opt) => opt.value === value);
|
||||
if (option) return option;
|
||||
|
||||
// If not found, check in search results
|
||||
const searchOption = searchOptions.find((opt) => opt.value === value);
|
||||
if (searchOption) return searchOption;
|
||||
// If not found, check in search cache
|
||||
const repo = Object.values(searchCache.current)
|
||||
.flat()
|
||||
.find((r) => r.id === value);
|
||||
|
||||
if (repo) {
|
||||
return {
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [allOptions, searchOptions, value]);
|
||||
}, [allOptions, value]);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (inputValue: string): Promise<AsyncSelectOption[]> => {
|
||||
// Update search input to trigger debounced search
|
||||
setSearchInput(inputValue);
|
||||
|
||||
// If empty input, show all loaded options
|
||||
if (!inputValue.trim()) {
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
// For very short inputs, do local filtering
|
||||
if (inputValue.length < 2) {
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle URL inputs by performing direct search
|
||||
// If it looks like a URL, extract the repo name and search
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
try {
|
||||
// Perform direct search for URL-based inputs
|
||||
const repositories = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
provider,
|
||||
);
|
||||
return repositories.map((repo) => ({
|
||||
value: repo.full_name,
|
||||
label: repo.full_name,
|
||||
data: repo,
|
||||
}));
|
||||
} catch (error) {
|
||||
// Fall back to local filtering if search fails
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(repoName.toLowerCase()),
|
||||
);
|
||||
}
|
||||
const searchResults = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
);
|
||||
// Cache the search results
|
||||
searchCache.current[repoName] = searchResults;
|
||||
return searchResults.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// For regular text inputs, use hook-based search results if available
|
||||
if (searchOptions.length > 0 && processedSearchInput === inputValue) {
|
||||
return searchOptions;
|
||||
// For any other input, search via API
|
||||
if (inputValue.length >= 2) {
|
||||
// Only search if at least 2 characters
|
||||
const searchResults = await OpenHands.searchGitRepositories(
|
||||
inputValue,
|
||||
10,
|
||||
);
|
||||
// Cache the search results
|
||||
searchCache.current[inputValue] = searchResults;
|
||||
return searchResults.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback to local filtering while search is loading
|
||||
// For very short inputs, do local filtering
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[allOptions, searchOptions, processedSearchInput, provider],
|
||||
[allOptions],
|
||||
);
|
||||
|
||||
const handleChange = (option: AsyncSelectOption | null) => {
|
||||
@@ -166,7 +142,9 @@ export function GitRepositoryDropdown({
|
||||
|
||||
// If not found, check in search results
|
||||
if (!repo) {
|
||||
repo = searchData?.find((r) => r.id === option.value);
|
||||
repo = Object.values(searchCache.current)
|
||||
.flat()
|
||||
.find((r) => r.id === option.value);
|
||||
}
|
||||
|
||||
onChange?.(repo);
|
||||
@@ -189,7 +167,7 @@ export function GitRepositoryDropdown({
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isLoading={isLoading || isFetchingNextPage || isSearchLoading}
|
||||
isLoading={isLoading || isLoading || isFetchingNextPage}
|
||||
cacheOptions
|
||||
defaultOptions={allOptions}
|
||||
onChange={handleChange}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export function MicroagentManagementAccordionTitle({
|
||||
<TooltipButton
|
||||
tooltip={repository.full_name}
|
||||
ariaLabel={repository.full_name}
|
||||
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[200px] translate-y-[-1px]"
|
||||
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[232px]"
|
||||
testId="repository-name-tooltip"
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
+14
-11
@@ -7,6 +7,8 @@ import {
|
||||
} from "#/state/microagent-management-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import PlusIcon from "#/icons/plus.svg?react";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface MicroagentManagementAddMicroagentButtonProps {
|
||||
repository: GitRepository;
|
||||
@@ -23,22 +25,23 @@ export function MicroagentManagementAddMicroagentButton({
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
|
||||
dispatch(setSelectedRepository(repository));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="translate-y-[-1px]"
|
||||
data-testid="add-microagent-button"
|
||||
>
|
||||
<span className="text-sm font-normal leading-5 text-[#8480FF] cursor-pointer hover:text-[#6C63FF] transition-colors duration-200">
|
||||
{t(I18nKey.COMMON$ADD_MICROAGENT)}
|
||||
</span>
|
||||
</button>
|
||||
<div onClick={handleClick}>
|
||||
<TooltipButton
|
||||
tooltip={t(I18nKey.COMMON$ADD_MICROAGENT)}
|
||||
ariaLabel={t(I18nKey.COMMON$ADD_MICROAGENT)}
|
||||
className="p-0 min-w-0 h-6 w-6 flex items-center justify-center bg-transparent cursor-pointer"
|
||||
testId="add-microagent-button"
|
||||
placement="bottom"
|
||||
>
|
||||
<PlusIcon width={22} height={22} />
|
||||
</TooltipButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-40
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
|
||||
import { MicroagentManagementMain } from "./microagent-management-main";
|
||||
@@ -26,12 +25,6 @@ import { GitRepository } from "#/types/git";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { MicroagentManagementLearnThisRepoModal } from "./microagent-management-learn-this-repo-modal";
|
||||
import {
|
||||
displaySuccessToast,
|
||||
displayErrorToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { getFirstPRUrl } from "#/utils/parse-pr-url";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
// Handle error events
|
||||
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
|
||||
@@ -119,8 +112,6 @@ export function MicroagentManagementContent() {
|
||||
learnThisRepoModalVisible,
|
||||
} = useSelector((state: RootState) => state.microagentManagement);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { createConversationAndSubscribe, isPending } =
|
||||
@@ -168,37 +159,6 @@ export function MicroagentManagementContent() {
|
||||
? (selectedRepository as GitRepository).full_name
|
||||
: "";
|
||||
|
||||
// Check if agent is running and ready to work
|
||||
if (
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
isAgentStateChangeObservation(socketEvent) &&
|
||||
socketEvent.extras.agent_state === AgentState.RUNNING
|
||||
) {
|
||||
displaySuccessToast(
|
||||
t(I18nKey.MICROAGENT_MANAGEMENT$OPENING_PR_TO_CREATE_MICROAGENT),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if agent has finished and we have a PR
|
||||
if (isOpenHandsEvent(socketEvent) && isFinishAction(socketEvent)) {
|
||||
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
|
||||
if (prUrl) {
|
||||
displaySuccessToast(
|
||||
t(I18nKey.MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW),
|
||||
);
|
||||
} else {
|
||||
// Agent finished but no PR found
|
||||
displaySuccessToast(t(I18nKey.MICROAGENT_MANAGEMENT$PR_NOT_CREATED));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error events
|
||||
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
|
||||
displayErrorToast(
|
||||
t(I18nKey.MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT),
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldInvalidateConversationsList(socketEvent)) {
|
||||
invalidateConversationsList(repositoryName);
|
||||
}
|
||||
|
||||
+2
-14
@@ -65,18 +65,6 @@ export function MicroagentManagementRepoMicroagents({
|
||||
}
|
||||
}, [conversations]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
dispatch(
|
||||
setSelectedMicroagentItem({
|
||||
microagent: null,
|
||||
conversation: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Show loading only when both queries are loading
|
||||
const isLoading = isLoadingMicroagents || isLoadingConversations;
|
||||
|
||||
@@ -94,7 +82,7 @@ export function MicroagentManagementRepoMicroagents({
|
||||
// If there's an error with microagents, show the learn this repo component
|
||||
if (isError) {
|
||||
return (
|
||||
<div>
|
||||
<div className="pb-4">
|
||||
<MicroagentManagementLearnThisRepo repository={repository} />
|
||||
</div>
|
||||
);
|
||||
@@ -105,7 +93,7 @@ export function MicroagentManagementRepoMicroagents({
|
||||
const totalItems = numberOfMicroagents + numberOfConversations;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="pb-4">
|
||||
{totalItems === 0 && (
|
||||
<MicroagentManagementLearnThisRepo repository={repository} />
|
||||
)}
|
||||
|
||||
+2
-4
@@ -97,10 +97,8 @@ export function MicroagentManagementRepositories({
|
||||
variant="splitted"
|
||||
className="w-full px-0 gap-3"
|
||||
itemClasses={{
|
||||
base: "shadow-none bg-transparent cursor-pointer px-0",
|
||||
trigger: "cursor-pointer gap-2 py-3",
|
||||
indicator:
|
||||
"flex items-center justify-center p-0.5 pr-[3px] text-white hover:bg-[#454545] rounded transition-colors duration-200 rotate-180",
|
||||
base: "shadow-none bg-transparent border border-[#ffffff40] rounded-[6px] cursor-pointer",
|
||||
trigger: "cursor-pointer gap-1",
|
||||
}}
|
||||
selectionMode="multiple"
|
||||
>
|
||||
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MCPServerForm } from "../mcp-server-form";
|
||||
|
||||
// i18n mock
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MCPServerForm validation", () => {
|
||||
const noop = () => {};
|
||||
|
||||
it("rejects invalid env var lines and allows blank lines", () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerForm
|
||||
mode="add"
|
||||
server={{ id: "tmp", type: "stdio" }}
|
||||
existingServers={[]}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Fill required fields
|
||||
fireEvent.change(screen.getByTestId("name-input"), {
|
||||
target: { value: "my-server" },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("command-input"), {
|
||||
target: { value: "npx" },
|
||||
});
|
||||
|
||||
// Invalid env entries mixed with blank lines
|
||||
fireEvent.change(screen.getByTestId("env-input"), {
|
||||
target: { value: "invalid\n\nKEY=value\n=novalue\nKEY_ONLY=" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("submit-button"));
|
||||
|
||||
// Should show invalid env format error
|
||||
expect(
|
||||
screen.getByText("SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Fix env with valid lines and blank lines
|
||||
fireEvent.change(screen.getByTestId("env-input"), {
|
||||
target: { value: "KEY=value\n\nANOTHER=123" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("submit-button"));
|
||||
|
||||
// No error; submit should be called
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects duplicate URLs across sse/shttp types", () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
const existingServers = [
|
||||
{ id: "sse-1", type: "sse" as const, url: "https://api.example.com" },
|
||||
{ id: "shttp-1", type: "shttp" as const, url: "https://x.example.com" },
|
||||
];
|
||||
|
||||
const r1 = render(
|
||||
<MCPServerForm
|
||||
mode="add"
|
||||
server={{ id: "tmp", type: "sse" }}
|
||||
existingServers={existingServers}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getAllByTestId("url-input")[0], {
|
||||
target: { value: "https://api.example.com" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByTestId("submit-button")[0]);
|
||||
expect(
|
||||
screen.getByText("SETTINGS$MCP_ERROR_URL_DUPLICATE"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Unmount first form, then check shttp duplicate
|
||||
r1.unmount();
|
||||
|
||||
const r2 = render(
|
||||
<MCPServerForm
|
||||
mode="add"
|
||||
server={{ id: "tmp2", type: "shttp" }}
|
||||
existingServers={existingServers}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getAllByTestId("url-input")[0], {
|
||||
target: { value: "https://api.example.com" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByTestId("submit-button")[0]);
|
||||
expect(
|
||||
screen.getByText("SETTINGS$MCP_ERROR_URL_DUPLICATE"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
r2.unmount();
|
||||
});
|
||||
});
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MCPServerList } from "../mcp-server-list";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockServers = [
|
||||
{
|
||||
id: "sse-0",
|
||||
type: "sse" as const,
|
||||
url: "https://very-long-url-that-could-cause-layout-overflow.example.com/api/v1/mcp/server/endpoint/with/many/path/segments",
|
||||
},
|
||||
{
|
||||
id: "stdio-0",
|
||||
type: "stdio" as const,
|
||||
name: "test-stdio-server",
|
||||
command: "python",
|
||||
args: ["-m", "test_server"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("MCPServerList", () => {
|
||||
it("should render servers with proper layout structure", () => {
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={mockServers}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the table structure is rendered
|
||||
const table = screen.getByRole("table");
|
||||
expect(table).toBeInTheDocument();
|
||||
expect(table).toHaveClass("w-full");
|
||||
|
||||
// Check that server items are rendered
|
||||
const serverItems = screen.getAllByTestId("mcp-server-item");
|
||||
expect(serverItems).toHaveLength(2);
|
||||
|
||||
// Check that action buttons are present for each server
|
||||
const editButtons = screen.getAllByTestId("edit-mcp-server-button");
|
||||
const deleteButtons = screen.getAllByTestId("delete-mcp-server-button");
|
||||
expect(editButtons).toHaveLength(2);
|
||||
expect(deleteButtons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should render empty state when no servers", () => {
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={[]}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("SETTINGS$MCP_NO_SERVERS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle long URLs without breaking layout", () => {
|
||||
const longUrlServer = {
|
||||
id: "sse-0",
|
||||
type: "sse" as const,
|
||||
url: "https://extremely-long-url-that-would-previously-cause-layout-overflow-and-push-action-buttons-out-of-view.example.com/api/v1/mcp/server/endpoint/with/many/path/segments/and/query/parameters?param1=value1¶m2=value2¶m3=value3",
|
||||
};
|
||||
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={[longUrlServer]}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that action buttons are still present and accessible
|
||||
const editButton = screen.getByTestId("edit-mcp-server-button");
|
||||
const deleteButton = screen.getByTestId("delete-mcp-server-button");
|
||||
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
// Check that the URL is properly displayed with title attribute for accessibility
|
||||
const detailsCells = screen.getAllByTitle(longUrlServer.url);
|
||||
expect(detailsCells).toHaveLength(2); // Name and Details columns both have the URL
|
||||
|
||||
// Check that both name and details cells use truncation and have title for tooltip
|
||||
const [nameCell, detailsCell] = detailsCells;
|
||||
expect(nameCell).toHaveClass("truncate");
|
||||
expect(detailsCell).toHaveClass("truncate");
|
||||
});
|
||||
|
||||
it("should display command and arguments for STDIO servers", () => {
|
||||
const stdioServer = {
|
||||
id: "stdio-1",
|
||||
type: "stdio" as const,
|
||||
name: "test-server",
|
||||
command: "python",
|
||||
args: ["-m", "test_module", "--verbose"],
|
||||
};
|
||||
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={[stdioServer]}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the server details show command + arguments
|
||||
const expectedDetails = "python -m test_module --verbose";
|
||||
expect(screen.getByTitle(expectedDetails)).toBeInTheDocument();
|
||||
expect(screen.getByText(expectedDetails)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fallback to server name for STDIO servers without command", () => {
|
||||
const stdioServer = {
|
||||
id: "stdio-2",
|
||||
type: "stdio" as const,
|
||||
name: "fallback-server",
|
||||
};
|
||||
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={[stdioServer]}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the server details show the server name as fallback
|
||||
// Both name and details columns will have the same value, so we expect 2 elements
|
||||
const fallbackElements = screen.getAllByTitle("fallback-server");
|
||||
expect(fallbackElements).toHaveLength(2);
|
||||
|
||||
const fallbackTextElements = screen.getAllByText("fallback-server");
|
||||
expect(fallbackTextElements).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MCPConfig } from "#/types/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { MCPSSEServers } from "./mcp-sse-servers";
|
||||
import { MCPStdioServers } from "./mcp-stdio-servers";
|
||||
import { MCPJsonEditor } from "./mcp-json-editor";
|
||||
import { BrandButton } from "../brand-button";
|
||||
|
||||
interface MCPConfigEditorProps {
|
||||
mcpConfig?: MCPConfig;
|
||||
onChange: (config: MCPConfig) => void;
|
||||
}
|
||||
|
||||
export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const handleConfigChange = (newConfig: MCPConfig) => {
|
||||
onChange(newConfig);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const config = mcpConfig || { sse_servers: [], stdio_servers: [] };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<div className="text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$MCP_TITLE)}
|
||||
</div>
|
||||
<p className="text-xs text-[#A3A3A3]">
|
||||
{t(I18nKey.SETTINGS$MCP_DESCRIPTION)}
|
||||
</p>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$MCP_EDIT_CONFIGURATION)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{isEditing ? (
|
||||
<MCPJsonEditor
|
||||
mcpConfig={mcpConfig}
|
||||
onChange={handleConfigChange}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<MCPSSEServers servers={config.sse_servers} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MCPStdioServers servers={config.stdio_servers} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.sse_servers.length === 0 &&
|
||||
config.stdio_servers.length === 0 && (
|
||||
<div className="mt-4 p-2 bg-yellow-50 border border-yellow-200 rounded-md text-sm text-yellow-700">
|
||||
{t(I18nKey.SETTINGS$MCP_NO_SERVERS_CONFIGURED)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { MCPConfig } from "#/types/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface MCPJsonEditorProps {
|
||||
mcpConfig?: MCPConfig;
|
||||
onChange: (config: MCPConfig) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MCP_DEFAULT_CONFIG: MCPConfig = {
|
||||
sse_servers: [],
|
||||
stdio_servers: [],
|
||||
};
|
||||
|
||||
export function MCPJsonEditor({
|
||||
mcpConfig,
|
||||
onChange,
|
||||
onCancel,
|
||||
}: MCPJsonEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [configText, setConfigText] = useState(() =>
|
||||
mcpConfig
|
||||
? JSON.stringify(mcpConfig, null, 2)
|
||||
: JSON.stringify(MCP_DEFAULT_CONFIG, null, 2),
|
||||
);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setConfigText(e.target.value);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
const newConfig = JSON.parse(configText);
|
||||
|
||||
// Validate the structure
|
||||
if (!newConfig.sse_servers || !Array.isArray(newConfig.sse_servers)) {
|
||||
throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_SSE_ARRAY));
|
||||
}
|
||||
|
||||
if (!newConfig.stdio_servers || !Array.isArray(newConfig.stdio_servers)) {
|
||||
throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_STDIO_ARRAY));
|
||||
}
|
||||
|
||||
// Validate SSE servers
|
||||
for (const server of newConfig.sse_servers) {
|
||||
if (
|
||||
typeof server !== "string" &&
|
||||
(!server.url || typeof server.url !== "string")
|
||||
) {
|
||||
throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_SSE_URL));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate stdio servers
|
||||
for (const server of newConfig.stdio_servers) {
|
||||
if (!server.name || !server.command) {
|
||||
throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_STDIO_PROPS));
|
||||
}
|
||||
}
|
||||
|
||||
onChange(newConfig);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t(I18nKey.SETTINGS$MCP_ERROR_INVALID_JSON),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-gray-400">
|
||||
<Trans
|
||||
i18nKey={I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION}
|
||||
components={{
|
||||
a: (
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/mcp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
"w-full h-64 resize-y p-2 rounded-sm text-sm font-mono",
|
||||
"bg-tertiary border border-[#717888]",
|
||||
"placeholder:italic placeholder:text-tertiary-alt",
|
||||
"focus:outline-none focus:ring-1 focus:ring-primary",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
value={configText}
|
||||
onChange={handleTextChange}
|
||||
spellCheck="false"
|
||||
/>
|
||||
{error && (
|
||||
<div className="mt-2 p-2 bg-red-100 border border-red-300 rounded-md text-sm text-red-700">
|
||||
<strong>{t(I18nKey.SETTINGS$MCP_CONFIG_ERROR)}</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
<strong>{t(I18nKey.SETTINGS$MCP_CONFIG_EXAMPLE)}</strong>{" "}
|
||||
<code>
|
||||
{
|
||||
'{ "sse_servers": ["https://example-mcp-server.com/sse"], "stdio_servers": [{ "name": "fetch", "command": "uvx", "args": ["mcp-server-fetch"] }] }'
|
||||
}
|
||||
</code>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<BrandButton type="button" variant="secondary" onClick={onCancel}>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton type="button" variant="primary" onClick={handleSave}>
|
||||
{t(I18nKey.SETTINGS$MCP_PREVIEW_CHANGES)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
import { SettingsDropdownInput } from "../settings-dropdown-input";
|
||||
import { BrandButton } from "../brand-button";
|
||||
import { OptionalTag } from "../optional-tag";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
|
||||
interface MCPServerConfig {
|
||||
id: string;
|
||||
type: MCPServerType;
|
||||
name?: string;
|
||||
url?: string;
|
||||
api_key?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface MCPServerFormProps {
|
||||
mode: "add" | "edit";
|
||||
server?: MCPServerConfig;
|
||||
existingServers?: MCPServerConfig[];
|
||||
onSubmit: (server: MCPServerConfig) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MCPServerForm({
|
||||
mode,
|
||||
server,
|
||||
existingServers,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: MCPServerFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [serverType, setServerType] = React.useState<MCPServerType>(
|
||||
server?.type || "sse",
|
||||
);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const serverTypeOptions = [
|
||||
{ key: "sse", label: t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SSE) },
|
||||
{ key: "stdio", label: t(I18nKey.SETTINGS$MCP_SERVER_TYPE_STDIO) },
|
||||
{ key: "shttp", label: t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SHTTP) },
|
||||
];
|
||||
|
||||
const validateUrl = (url: string): string | null => {
|
||||
if (!url) return t(I18nKey.SETTINGS$MCP_ERROR_URL_REQUIRED);
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (!["http:", "https:"].includes(urlObj.protocol)) {
|
||||
return t(I18nKey.SETTINGS$MCP_ERROR_URL_INVALID_PROTOCOL);
|
||||
}
|
||||
} catch {
|
||||
return t(I18nKey.SETTINGS$MCP_ERROR_URL_INVALID);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateName = (name: string): string | null => {
|
||||
if (!name) return t(I18nKey.SETTINGS$MCP_ERROR_NAME_REQUIRED);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
return t(I18nKey.SETTINGS$MCP_ERROR_NAME_INVALID);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateNameUniqueness = (name: string): string | null => {
|
||||
if (!existingServers) return null;
|
||||
const shouldCheckUniqueness =
|
||||
mode === "add" || (mode === "edit" && server?.name !== name);
|
||||
if (!shouldCheckUniqueness) return null;
|
||||
|
||||
const existingStdioNames = existingServers
|
||||
.filter((s) => s.type === "stdio")
|
||||
.map((s) => s.name)
|
||||
.filter(Boolean);
|
||||
if (existingStdioNames.includes(name)) {
|
||||
return t(I18nKey.SETTINGS$MCP_ERROR_NAME_DUPLICATE);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateCommand = (command: string): string | null => {
|
||||
if (!command) return t(I18nKey.SETTINGS$MCP_ERROR_COMMAND_REQUIRED);
|
||||
if (command.includes(" ")) {
|
||||
return t(I18nKey.SETTINGS$MCP_ERROR_COMMAND_NO_SPACES);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateUrlUniqueness = (url: string): string | null => {
|
||||
if (!existingServers) return null;
|
||||
const originalUrl = server?.url;
|
||||
const changed = mode === "add" || (mode === "edit" && originalUrl !== url);
|
||||
if (!changed) return null;
|
||||
// For URL-based servers (sse/shttp), ensure URL is unique across both types
|
||||
const exists = existingServers.some(
|
||||
(s) => (s.type === "sse" || s.type === "shttp") && s.url === url,
|
||||
);
|
||||
if (exists) return t(I18nKey.SETTINGS$MCP_ERROR_URL_DUPLICATE);
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateEnvFormat = (envString: string): string | null => {
|
||||
if (!envString.trim()) return null;
|
||||
const lines = envString.split("\n");
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed) {
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) return t(I18nKey.SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT);
|
||||
const key = trimmed.substring(0, eq).trim();
|
||||
if (!key) return t(I18nKey.SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateStdioServer = (formData: FormData): string | null => {
|
||||
const name = formData.get("name")?.toString().trim() || "";
|
||||
const command = formData.get("command")?.toString().trim() || "";
|
||||
const envString = formData.get("env")?.toString() || "";
|
||||
|
||||
const nameError = validateName(name);
|
||||
if (nameError) return nameError;
|
||||
|
||||
const uniquenessError = validateNameUniqueness(name);
|
||||
if (uniquenessError) return uniquenessError;
|
||||
|
||||
const commandError = validateCommand(command);
|
||||
if (commandError) return commandError;
|
||||
|
||||
// Validate environment variable format
|
||||
const envError = validateEnvFormat(envString);
|
||||
if (envError) return envError;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateForm = (formData: FormData): string | null => {
|
||||
if (serverType === "sse" || serverType === "shttp") {
|
||||
const url = formData.get("url")?.toString().trim() || "";
|
||||
const urlError = validateUrl(url);
|
||||
if (urlError) return urlError;
|
||||
const urlDupError = validateUrlUniqueness(url);
|
||||
if (urlDupError) return urlDupError;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (serverType === "stdio") {
|
||||
return validateStdioServer(formData);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseEnvironmentVariables = (
|
||||
envString: string,
|
||||
): Record<string, string> => {
|
||||
const env: Record<string, string> = {};
|
||||
const input = envString.trim();
|
||||
if (!input) return env;
|
||||
|
||||
for (const line of input.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
const eq = trimmed.indexOf("=");
|
||||
const key = eq >= 0 ? trimmed.substring(0, eq).trim() : "";
|
||||
if (trimmed && eq !== -1 && key) {
|
||||
env[key] = trimmed.substring(eq + 1).trim();
|
||||
}
|
||||
}
|
||||
return env;
|
||||
};
|
||||
|
||||
const formatEnvironmentVariables = (env?: Record<string, string>): string => {
|
||||
if (!env) return "";
|
||||
return Object.entries(env)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const validationError = validateForm(formData);
|
||||
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
id: server?.id || `${serverType}-${Date.now()}`,
|
||||
type: serverType,
|
||||
};
|
||||
|
||||
if (serverType === "sse" || serverType === "shttp") {
|
||||
const url = formData.get("url")?.toString().trim();
|
||||
const apiKey = formData.get("api_key")?.toString().trim();
|
||||
|
||||
onSubmit({
|
||||
...baseConfig,
|
||||
url: url!,
|
||||
...(apiKey && { api_key: apiKey }),
|
||||
});
|
||||
} else if (serverType === "stdio") {
|
||||
const name = formData.get("name")?.toString().trim();
|
||||
const command = formData.get("command")?.toString().trim();
|
||||
const argsString = formData.get("args")?.toString().trim();
|
||||
const envString = formData.get("env")?.toString().trim();
|
||||
|
||||
const args = argsString
|
||||
? argsString
|
||||
.split("\n")
|
||||
.map((arg) => arg.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const env = parseEnvironmentVariables(envString || "");
|
||||
|
||||
onSubmit({
|
||||
...baseConfig,
|
||||
name: name!,
|
||||
command: command!,
|
||||
...(args.length > 0 && { args }),
|
||||
...(Object.keys(env).length > 0 && { env }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formTestId =
|
||||
mode === "add" ? "add-mcp-server-form" : "edit-mcp-server-form";
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid={formTestId}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col items-start gap-6"
|
||||
>
|
||||
{mode === "add" && (
|
||||
<SettingsDropdownInput
|
||||
testId="server-type-dropdown"
|
||||
name="server-type"
|
||||
label={t(I18nKey.SETTINGS$MCP_SERVER_TYPE)}
|
||||
items={serverTypeOptions}
|
||||
selectedKey={serverType}
|
||||
onSelectionChange={(key) => setServerType(key as MCPServerType)}
|
||||
onInputChange={() => {}} // Prevent input changes
|
||||
isClearable={false}
|
||||
allowsCustomValue={false}
|
||||
required
|
||||
wrapperClassName={cn("w-full", "max-w-[680px]")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
{(serverType === "sse" || serverType === "shttp") && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="url-input"
|
||||
name="url"
|
||||
type="url"
|
||||
label={t(I18nKey.SETTINGS$MCP_URL)}
|
||||
className="w-full max-w-[680px]"
|
||||
required
|
||||
defaultValue={server?.url || ""}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
testId="api-key-input"
|
||||
name="api_key"
|
||||
type="password"
|
||||
label={t(I18nKey.SETTINGS$MCP_API_KEY)}
|
||||
className="w-full max-w-[680px]"
|
||||
showOptionalTag
|
||||
defaultValue={server?.api_key || ""}
|
||||
placeholder={t(I18nKey.SETTINGS$MCP_API_KEY_PLACEHOLDER)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{serverType === "stdio" && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="name-input"
|
||||
name="name"
|
||||
type="text"
|
||||
label={t(I18nKey.SETTINGS$MCP_NAME)}
|
||||
className="w-full max-w-[680px]"
|
||||
required
|
||||
defaultValue={server?.name || ""}
|
||||
placeholder="my-mcp-server"
|
||||
pattern="^[a-zA-Z0-9_-]+$"
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
testId="command-input"
|
||||
name="command"
|
||||
type="text"
|
||||
label={t(I18nKey.SETTINGS$MCP_COMMAND)}
|
||||
className="w-full max-w-[680px]"
|
||||
required
|
||||
defaultValue={server?.command || ""}
|
||||
placeholder="npx"
|
||||
/>
|
||||
|
||||
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">
|
||||
{t(I18nKey.SETTINGS$MCP_COMMAND_ARGUMENTS)}
|
||||
</span>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<textarea
|
||||
data-testid="args-input"
|
||||
name="args"
|
||||
rows={3}
|
||||
defaultValue={server?.args?.join("\n") || ""}
|
||||
placeholder="arg1 arg2 arg3"
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-tertiary-alt">
|
||||
{t(I18nKey.SETTINGS$MCP_COMMAND_ARGUMENTS_HELP)}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">
|
||||
{t(I18nKey.SETTINGS$MCP_ENVIRONMENT_VARIABLES)}
|
||||
</span>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<textarea
|
||||
data-testid="env-input"
|
||||
name="env"
|
||||
rows={4}
|
||||
defaultValue={formatEnvironmentVariables(server?.env)}
|
||||
placeholder="KEY1=value1 KEY2=value2"
|
||||
className={cn(
|
||||
"resize-none",
|
||||
"bg-tertiary border border-[#717888] rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<BrandButton
|
||||
testId="cancel-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton testId="submit-button" type="submit" variant="primary">
|
||||
{mode === "add" && t(I18nKey.SETTINGS$MCP_ADD_SERVER)}
|
||||
{mode === "edit" && t(I18nKey.SETTINGS$MCP_SAVE_SERVER)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { FaPencil, FaTrash } from "react-icons/fa6";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface MCPServerConfig {
|
||||
id: string;
|
||||
type: "sse" | "stdio" | "shttp";
|
||||
name?: string;
|
||||
url?: string;
|
||||
api_key?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function MCPServerListItem({
|
||||
server,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
server: MCPServerConfig;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getServerTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "sse":
|
||||
return t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SSE);
|
||||
case "stdio":
|
||||
return t(I18nKey.SETTINGS$MCP_SERVER_TYPE_STDIO);
|
||||
case "shttp":
|
||||
return t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SHTTP);
|
||||
default:
|
||||
return type.toUpperCase();
|
||||
}
|
||||
};
|
||||
|
||||
const getServerDescription = (serverConfig: MCPServerConfig) => {
|
||||
if (serverConfig.type === "stdio") {
|
||||
if (serverConfig.command) {
|
||||
const args =
|
||||
serverConfig.args && serverConfig.args.length > 0
|
||||
? ` ${serverConfig.args.join(" ")}`
|
||||
: "";
|
||||
return `${serverConfig.command}${args}`;
|
||||
}
|
||||
return serverConfig.name || "";
|
||||
}
|
||||
if (
|
||||
(serverConfig.type === "sse" || serverConfig.type === "shttp") &&
|
||||
serverConfig.url
|
||||
) {
|
||||
return serverConfig.url;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const serverName = server.type === "stdio" ? server.name : server.url;
|
||||
const serverDescription = getServerDescription(server);
|
||||
|
||||
return (
|
||||
<tr
|
||||
data-testid="mcp-server-item"
|
||||
className="grid grid-cols-[minmax(0,0.25fr)_120px_minmax(0,1fr)_120px] gap-4 items-start border-t border-tertiary"
|
||||
>
|
||||
<td
|
||||
className="p-3 text-sm text-content-2 truncate min-w-0"
|
||||
title={serverName}
|
||||
>
|
||||
{serverName}
|
||||
</td>
|
||||
|
||||
<td className="p-3 text-sm text-content-2 whitespace-nowrap">
|
||||
{getServerTypeLabel(server.type)}
|
||||
</td>
|
||||
|
||||
<td
|
||||
className="p-3 text-sm text-content-2 opacity-80 italic min-w-0 truncate"
|
||||
title={serverDescription}
|
||||
>
|
||||
<span className="inline-block max-w-full align-bottom">
|
||||
{serverDescription}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="p-3 flex items-start justify-end gap-4 whitespace-nowrap">
|
||||
<button
|
||||
data-testid="edit-mcp-server-button"
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={`Edit ${serverName}`}
|
||||
className="cursor-pointer hover:text-content-1 transition-colors"
|
||||
>
|
||||
<FaPencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="delete-mcp-server-button"
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete ${serverName}`}
|
||||
className="cursor-pointer hover:text-content-1 transition-colors"
|
||||
>
|
||||
<FaTrash size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MCPServerListItem } from "./mcp-server-list-item";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface MCPServerConfig {
|
||||
id: string;
|
||||
type: "sse" | "stdio" | "shttp";
|
||||
name?: string;
|
||||
url?: string;
|
||||
api_key?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface MCPServerListProps {
|
||||
servers: MCPServerConfig[];
|
||||
onEdit: (server: MCPServerConfig) => void;
|
||||
onDelete: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export function MCPServerList({
|
||||
servers,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: MCPServerListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className="border border-tertiary rounded-md p-8 text-center">
|
||||
<p className="text-content-2 text-sm">
|
||||
{t(I18nKey.SETTINGS$MCP_NO_SERVERS)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-tertiary rounded-md overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-base-tertiary">
|
||||
<tr className="grid grid-cols-[minmax(0,0.25fr)_120px_minmax(0,1fr)_120px] gap-4 items-start">
|
||||
<th className="text-left p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$NAME)}
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$MCP_SERVER_TYPE)}
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$MCP_SERVER_DETAILS)}
|
||||
</th>
|
||||
<th className="text-right p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$ACTIONS)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map((server) => (
|
||||
<MCPServerListItem
|
||||
key={server.id}
|
||||
server={server}
|
||||
onEdit={() => onEdit(server)}
|
||||
onDelete={() => onDelete(server.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
|
||||
<div className="fixed inset-0 flex items-center justify-center z-20">
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="fixed inset-0 bg-black opacity-60"
|
||||
className="fixed inset-0 bg-black bg-opacity-80"
|
||||
/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
|
||||
interface MCPServerConfig {
|
||||
type: MCPServerType;
|
||||
name?: string;
|
||||
url?: string;
|
||||
api_key?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function useAddMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (server: MCPServerConfig): Promise<void> => {
|
||||
if (!settings) return;
|
||||
|
||||
const currentConfig = settings.MCP_CONFIG || {
|
||||
sse_servers: [],
|
||||
stdio_servers: [],
|
||||
shttp_servers: [],
|
||||
};
|
||||
|
||||
const newConfig = { ...currentConfig };
|
||||
|
||||
if (server.type === "sse") {
|
||||
const sseServer: MCPSSEServer = {
|
||||
url: server.url!,
|
||||
...(server.api_key && { api_key: server.api_key }),
|
||||
};
|
||||
newConfig.sse_servers.push(sseServer);
|
||||
} else if (server.type === "stdio") {
|
||||
const stdioServer: MCPStdioServer = {
|
||||
name: server.name!,
|
||||
command: server.command!,
|
||||
...(server.args && { args: server.args }),
|
||||
...(server.env && { env: server.env }),
|
||||
};
|
||||
newConfig.stdio_servers.push(stdioServer);
|
||||
} else if (server.type === "shttp") {
|
||||
const shttpServer: MCPSHTTPServer = {
|
||||
url: server.url!,
|
||||
...(server.api_key && { api_key: server.api_key }),
|
||||
};
|
||||
newConfig.shttp_servers.push(shttpServer);
|
||||
}
|
||||
|
||||
const apiSettings = {
|
||||
mcp_config: newConfig,
|
||||
};
|
||||
|
||||
await OpenHands.saveSettings(apiSettings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate the settings query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MCPConfig } from "#/types/settings";
|
||||
|
||||
export function useDeleteMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (serverId: string): Promise<void> => {
|
||||
if (!settings?.MCP_CONFIG) return;
|
||||
|
||||
const newConfig: MCPConfig = { ...settings.MCP_CONFIG };
|
||||
const [serverType, indexStr] = serverId.split("-");
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
if (serverType === "sse") {
|
||||
newConfig.sse_servers.splice(index, 1);
|
||||
} else if (serverType === "stdio") {
|
||||
newConfig.stdio_servers.splice(index, 1);
|
||||
} else if (serverType === "shttp") {
|
||||
newConfig.shttp_servers.splice(index, 1);
|
||||
}
|
||||
|
||||
const apiSettings = {
|
||||
mcp_config: newConfig,
|
||||
};
|
||||
|
||||
await OpenHands.saveSettings(apiSettings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate the settings query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
|
||||
interface MCPServerConfig {
|
||||
type: MCPServerType;
|
||||
name?: string;
|
||||
url?: string;
|
||||
api_key?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function useUpdateMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
serverId,
|
||||
server,
|
||||
}: {
|
||||
serverId: string;
|
||||
server: MCPServerConfig;
|
||||
}): Promise<void> => {
|
||||
if (!settings?.MCP_CONFIG) return;
|
||||
|
||||
const newConfig = { ...settings.MCP_CONFIG };
|
||||
const [serverType, indexStr] = serverId.split("-");
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
if (serverType === "sse") {
|
||||
const sseServer: MCPSSEServer = {
|
||||
url: server.url!,
|
||||
...(server.api_key && { api_key: server.api_key }),
|
||||
};
|
||||
newConfig.sse_servers[index] = sseServer;
|
||||
} else if (serverType === "stdio") {
|
||||
const stdioServer: MCPStdioServer = {
|
||||
name: server.name!,
|
||||
command: server.command!,
|
||||
...(server.args && { args: server.args }),
|
||||
...(server.env && { env: server.env }),
|
||||
};
|
||||
newConfig.stdio_servers[index] = stdioServer;
|
||||
} else if (serverType === "shttp") {
|
||||
const shttpServer: MCPSHTTPServer = {
|
||||
url: server.url!,
|
||||
...(server.api_key && { api_key: server.api_key }),
|
||||
};
|
||||
newConfig.shttp_servers[index] = shttpServer;
|
||||
}
|
||||
|
||||
const apiSettings = {
|
||||
mcp_config: newConfig,
|
||||
};
|
||||
|
||||
await OpenHands.saveSettings(apiSettings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate the settings query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -781,37 +781,4 @@ export enum I18nKey {
|
||||
PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR = "PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR",
|
||||
PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR = "PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR",
|
||||
MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT = "MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT",
|
||||
SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT = "SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT",
|
||||
SETTINGS$MCP_ERROR_URL_DUPLICATE = "SETTINGS$MCP_ERROR_URL_DUPLICATE",
|
||||
SETTINGS$MCP_SERVER_TYPE_SSE = "SETTINGS$MCP_SERVER_TYPE_SSE",
|
||||
SETTINGS$MCP_SERVER_TYPE_STDIO = "SETTINGS$MCP_SERVER_TYPE_STDIO",
|
||||
SETTINGS$MCP_SERVER_TYPE_SHTTP = "SETTINGS$MCP_SERVER_TYPE_SHTTP",
|
||||
SETTINGS$MCP_ERROR_URL_REQUIRED = "SETTINGS$MCP_ERROR_URL_REQUIRED",
|
||||
SETTINGS$MCP_ERROR_URL_INVALID_PROTOCOL = "SETTINGS$MCP_ERROR_URL_INVALID_PROTOCOL",
|
||||
SETTINGS$MCP_ERROR_URL_INVALID = "SETTINGS$MCP_ERROR_URL_INVALID",
|
||||
SETTINGS$MCP_ERROR_NAME_REQUIRED = "SETTINGS$MCP_ERROR_NAME_REQUIRED",
|
||||
SETTINGS$MCP_ERROR_NAME_INVALID = "SETTINGS$MCP_ERROR_NAME_INVALID",
|
||||
SETTINGS$MCP_ERROR_NAME_DUPLICATE = "SETTINGS$MCP_ERROR_NAME_DUPLICATE",
|
||||
SETTINGS$MCP_ERROR_COMMAND_REQUIRED = "SETTINGS$MCP_ERROR_COMMAND_REQUIRED",
|
||||
SETTINGS$MCP_ERROR_COMMAND_NO_SPACES = "SETTINGS$MCP_ERROR_COMMAND_NO_SPACES",
|
||||
SETTINGS$MCP_SERVER_TYPE = "SETTINGS$MCP_SERVER_TYPE",
|
||||
SETTINGS$MCP_API_KEY_PLACEHOLDER = "SETTINGS$MCP_API_KEY_PLACEHOLDER",
|
||||
SETTINGS$MCP_COMMAND_ARGUMENTS = "SETTINGS$MCP_COMMAND_ARGUMENTS",
|
||||
SETTINGS$MCP_COMMAND_ARGUMENTS_HELP = "SETTINGS$MCP_COMMAND_ARGUMENTS_HELP",
|
||||
SETTINGS$MCP_ENVIRONMENT_VARIABLES = "SETTINGS$MCP_ENVIRONMENT_VARIABLES",
|
||||
SETTINGS$MCP_ADD_SERVER = "SETTINGS$MCP_ADD_SERVER",
|
||||
SETTINGS$MCP_SAVE_SERVER = "SETTINGS$MCP_SAVE_SERVER",
|
||||
SETTINGS$MCP_NO_SERVERS = "SETTINGS$MCP_NO_SERVERS",
|
||||
SETTINGS$MCP_SERVER_DETAILS = "SETTINGS$MCP_SERVER_DETAILS",
|
||||
SETTINGS$MCP_CONFIRM_DELETE = "SETTINGS$MCP_CONFIRM_DELETE",
|
||||
SETTINGS$MCP_CONFIRM_CHANGES = "SETTINGS$MCP_CONFIRM_CHANGES",
|
||||
SETTINGS$MCP_DEFAULT_CONFIG = "SETTINGS$MCP_DEFAULT_CONFIG",
|
||||
PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER",
|
||||
PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION",
|
||||
PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION = "PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION",
|
||||
SETTINGS = "SETTINGS",
|
||||
MICROAGENT_MANAGEMENT$OPENING_PR_TO_CREATE_MICROAGENT = "MICROAGENT_MANAGEMENT$OPENING_PR_TO_CREATE_MICROAGENT",
|
||||
MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW = "MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW",
|
||||
MICROAGENT_MANAGEMENT$PR_NOT_CREATED = "MICROAGENT_MANAGEMENT$PR_NOT_CREATED",
|
||||
MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT = "MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT",
|
||||
}
|
||||
|
||||
+12482
-13010
File diff suppressed because it is too large
Load Diff
@@ -1,191 +1,86 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useDeleteMcpServer } from "#/hooks/mutation/use-delete-mcp-server";
|
||||
import { useAddMcpServer } from "#/hooks/mutation/use-add-mcp-server";
|
||||
import { useUpdateMcpServer } from "#/hooks/mutation/use-update-mcp-server";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
import { MCPServerList } from "#/components/features/settings/mcp-settings/mcp-server-list";
|
||||
import { MCPServerForm } from "#/components/features/settings/mcp-settings/mcp-server-form";
|
||||
import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { MCPConfig } from "#/types/settings";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
|
||||
interface MCPServerConfig {
|
||||
id: string;
|
||||
type: MCPServerType;
|
||||
name?: string;
|
||||
url?: string;
|
||||
api_key?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
import { MCPConfigEditor } from "#/components/features/settings/mcp-settings/mcp-config-editor";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
function MCPSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
const { mutate: deleteMcpServer } = useDeleteMcpServer();
|
||||
const { mutate: addMcpServer } = useAddMcpServer();
|
||||
const { mutate: updateMcpServer } = useUpdateMcpServer();
|
||||
const { mutate: saveSettings, isPending } = useSaveSettings();
|
||||
|
||||
const [view, setView] = useState<"list" | "add" | "edit">("list");
|
||||
const [editingServer, setEditingServer] = useState<MCPServerConfig | null>(
|
||||
null,
|
||||
);
|
||||
const [confirmationModalIsVisible, setConfirmationModalIsVisible] =
|
||||
useState(false);
|
||||
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
|
||||
const [mcpConfig, setMcpConfig] = useState<MCPConfig | undefined>(undefined);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
const mcpConfig: MCPConfig = settings?.MCP_CONFIG || {
|
||||
sse_servers: [],
|
||||
stdio_servers: [],
|
||||
shttp_servers: [],
|
||||
useEffect(() => {
|
||||
if (!mcpConfig && settings?.MCP_CONFIG) {
|
||||
setMcpConfig(settings.MCP_CONFIG);
|
||||
}
|
||||
}, [settings, mcpConfig]);
|
||||
|
||||
const handleConfigChange = (config: MCPConfig) => {
|
||||
setMcpConfig(config);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
// Convert servers to a unified format for display
|
||||
const allServers: MCPServerConfig[] = [
|
||||
...mcpConfig.sse_servers.map((server, index) => ({
|
||||
id: `sse-${index}`,
|
||||
type: "sse" as const,
|
||||
url: typeof server === "string" ? server : server.url,
|
||||
api_key: typeof server === "object" ? server.api_key : undefined,
|
||||
})),
|
||||
...mcpConfig.stdio_servers.map((server, index) => ({
|
||||
id: `stdio-${index}`,
|
||||
type: "stdio" as const,
|
||||
name: server.name,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
})),
|
||||
...mcpConfig.shttp_servers.map((server, index) => ({
|
||||
id: `shttp-${index}`,
|
||||
type: "shttp" as const,
|
||||
url: typeof server === "string" ? server : server.url,
|
||||
api_key: typeof server === "object" ? server.api_key : undefined,
|
||||
})),
|
||||
];
|
||||
const formAction = () => {
|
||||
if (!settings) return;
|
||||
|
||||
const handleAddServer = (serverConfig: MCPServerConfig) => {
|
||||
addMcpServer(serverConfig, {
|
||||
onSuccess: () => {
|
||||
setView("list");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditServer = (serverConfig: MCPServerConfig) => {
|
||||
updateMcpServer(
|
||||
{
|
||||
serverId: serverConfig.id,
|
||||
server: serverConfig,
|
||||
},
|
||||
saveSettings(
|
||||
{ MCP_CONFIG: mcpConfig },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setView("list");
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
posthog.capture("settings_saved", {
|
||||
HAS_MCP_CONFIG: mcpConfig ? "YES" : "NO",
|
||||
MCP_SSE_SERVERS_COUNT: mcpConfig?.sse_servers?.length || 0,
|
||||
MCP_STDIO_SERVERS_COUNT: mcpConfig?.stdio_servers?.length || 0,
|
||||
});
|
||||
setIsDirty(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteServer = (serverId: string) => {
|
||||
deleteMcpServer(serverId, {
|
||||
onSuccess: () => {
|
||||
setConfirmationModalIsVisible(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditClick = (server: MCPServerConfig) => {
|
||||
setEditingServer(server);
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleDeleteClick = (serverId: string) => {
|
||||
setServerToDelete(serverId);
|
||||
setConfirmationModalIsVisible(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (serverToDelete) {
|
||||
handleDeleteServer(serverToDelete);
|
||||
setServerToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setConfirmationModalIsVisible(false);
|
||||
setServerToDelete(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-11 py-9 flex flex-col gap-5">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-300 rounded w-1/4 mb-4" />
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2 mb-8" />
|
||||
<div className="h-10 bg-gray-300 rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="p-9">{t(I18nKey.HOME$LOADING)}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-11 py-9 flex flex-col gap-5">
|
||||
{view === "list" && (
|
||||
<>
|
||||
<BrandButton
|
||||
testId="add-mcp-server-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => setView("add")}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$MCP_ADD_SERVER)}
|
||||
</BrandButton>
|
||||
<form
|
||||
data-testid="mcp-settings-screen"
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
<div className="p-9 flex flex-col gap-12">
|
||||
<MCPConfigEditor mcpConfig={mcpConfig} onChange={handleConfigChange} />
|
||||
</div>
|
||||
|
||||
<MCPServerList
|
||||
servers={allServers}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "add" && (
|
||||
<MCPServerForm
|
||||
mode="add"
|
||||
existingServers={allServers}
|
||||
onSubmit={handleAddServer}
|
||||
onCancel={() => setView("list")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "edit" && editingServer && (
|
||||
<MCPServerForm
|
||||
mode="edit"
|
||||
server={editingServer}
|
||||
existingServers={allServers}
|
||||
onSubmit={handleEditServer}
|
||||
onCancel={() => {
|
||||
setView("list");
|
||||
setEditingServer(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmationModalIsVisible && (
|
||||
<ConfirmationModal
|
||||
text={t(I18nKey.SETTINGS$MCP_CONFIRM_DELETE)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!isDirty || isPending}
|
||||
>
|
||||
{!isPending && t(I18nKey.SETTINGS$SAVE_CHANGES)}
|
||||
{isPending && t(I18nKey.SETTINGS$SAVING)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ const SAAS_NAV_ITEMS = [
|
||||
{ to: "/settings/billing", text: "SETTINGS$NAV_CREDITS" },
|
||||
{ to: "/settings/secrets", text: "SETTINGS$NAV_SECRETS" },
|
||||
{ to: "/settings/api-keys", text: "SETTINGS$NAV_API_KEYS" },
|
||||
{ to: "/settings/mcp", text: "SETTINGS$NAV_MCP" },
|
||||
];
|
||||
|
||||
const OSS_NAV_ITEMS = [
|
||||
|
||||
@@ -26,7 +26,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
MCP_CONFIG: {
|
||||
sse_servers: [],
|
||||
stdio_servers: [],
|
||||
shttp_servers: [],
|
||||
},
|
||||
GIT_USER_NAME: "openhands",
|
||||
GIT_USER_EMAIL: "openhands@all-hands.dev",
|
||||
|
||||
@@ -24,15 +24,9 @@ export type MCPStdioServer = {
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type MCPSHTTPServer = {
|
||||
url: string;
|
||||
api_key?: string;
|
||||
};
|
||||
|
||||
export type MCPConfig = {
|
||||
sse_servers: (string | MCPSSEServer)[];
|
||||
stdio_servers: MCPStdioServer[];
|
||||
shttp_servers: (string | MCPSHTTPServer)[];
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
@@ -83,7 +77,6 @@ export type ApiSettings = {
|
||||
mcp_config?: {
|
||||
sse_servers: (string | MCPSSEServer)[];
|
||||
stdio_servers: MCPStdioServer[];
|
||||
shttp_servers: (string | MCPSHTTPServer)[];
|
||||
};
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
|
||||
@@ -83,7 +83,7 @@ from openhands.microagent.microagent import BaseMicroagent
|
||||
from openhands.runtime import get_runtime_cls
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.storage.settings.file_settings_store import FileSettingsStore
|
||||
from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
from openhands.utils.utils import create_registry_and_convo_stats
|
||||
|
||||
|
||||
async def cleanup_session(
|
||||
@@ -148,7 +148,7 @@ async def run_session(
|
||||
None, display_initialization_animation, 'Initializing...', is_loaded
|
||||
)
|
||||
|
||||
llm_registry, conversation_stats, config = create_registry_and_conversation_stats(
|
||||
llm_registry, convo_stats, config = create_registry_and_convo_stats(
|
||||
config,
|
||||
sid,
|
||||
None,
|
||||
@@ -169,9 +169,7 @@ async def run_session(
|
||||
|
||||
runtime.subscribe_to_shell_stream(stream_to_console)
|
||||
|
||||
controller, initial_state = create_controller(
|
||||
agent, runtime, config, conversation_stats
|
||||
)
|
||||
controller, initial_state = create_controller(agent, runtime, config, convo_stats)
|
||||
|
||||
event_stream = runtime.event_stream
|
||||
|
||||
@@ -275,7 +273,7 @@ async def run_session(
|
||||
|
||||
if event.agent_state == AgentState.RUNNING:
|
||||
display_agent_running_message()
|
||||
start_pause_listener(loop, is_paused, event_stream, config)
|
||||
start_pause_listener(loop, is_paused, event_stream)
|
||||
|
||||
def on_event(event: Event) -> None:
|
||||
loop.create_task(on_event_async(event))
|
||||
|
||||
+8
-181
@@ -87,9 +87,6 @@ COMMANDS = {
|
||||
|
||||
print_lock = threading.Lock()
|
||||
|
||||
# Lock to debounce sending Ctrl+C interrupts to the running command
|
||||
_interrupt_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
pause_task: asyncio.Task | None = None # No more than one pause task
|
||||
|
||||
|
||||
@@ -662,15 +659,6 @@ def display_help() -> None:
|
||||
commands_html += f'<gold><b>{command}</b></gold> - <grey>{description}</grey>\n'
|
||||
print_formatted_text(HTML(commands_html))
|
||||
|
||||
# Keyboard shortcuts section
|
||||
print_formatted_text(HTML('\nKeyboard shortcuts:'))
|
||||
shortcuts_html = (
|
||||
'<gold><b>Ctrl+P</b></gold> - <grey>Pause the agent</grey>\n'
|
||||
'<gold><b>Ctrl+C</b></gold> - <grey>Pause the agent; press twice quickly to interrupt a running command</grey>\n'
|
||||
'<gold><b>Ctrl+D</b></gold> - <grey>Pause the agent</grey>\n'
|
||||
)
|
||||
print_formatted_text(HTML(shortcuts_html))
|
||||
|
||||
# Footer
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
@@ -876,13 +864,12 @@ async def read_confirmation_input(config: OpenHandsConfig) -> str:
|
||||
def start_pause_listener(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
done_event: asyncio.Event,
|
||||
event_stream: EventStream,
|
||||
config: OpenHandsConfig,
|
||||
event_stream,
|
||||
) -> None:
|
||||
global pause_task
|
||||
if pause_task is None or pause_task.done():
|
||||
pause_task = loop.create_task(
|
||||
process_agent_pause(done_event, event_stream, config)
|
||||
process_agent_pause(done_event, event_stream)
|
||||
) # Create a task to track agent pause requests from the user
|
||||
|
||||
|
||||
@@ -896,135 +883,16 @@ async def stop_pause_listener() -> None:
|
||||
pause_task = None
|
||||
|
||||
|
||||
def is_command_running(event_stream: EventStream) -> bool:
|
||||
"""Check if a shell command is currently running using bounded reverse search.
|
||||
|
||||
We look at the latest relevant event (CmdRunAction or CmdOutputObservation):
|
||||
- If it's a CmdOutputObservation with a finalized exit_code (>= 0), no command is running
|
||||
- If it's a CmdOutputObservation with exit_code == -1, the command is still running (streaming)
|
||||
- If it's a CmdRunAction (non-input), we assume a command has started and is running
|
||||
"""
|
||||
try:
|
||||
from openhands.events.event_filter import EventFilter
|
||||
|
||||
filt = EventFilter(include_types=(CmdRunAction, CmdOutputObservation))
|
||||
for ev in event_stream.search_events(reverse=True, filter=filt, limit=50):
|
||||
if isinstance(ev, CmdOutputObservation):
|
||||
return ev.metadata.exit_code == -1
|
||||
if isinstance(ev, CmdRunAction):
|
||||
if ev.is_input:
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
# If detection fails for any reason, default to no running command
|
||||
return False
|
||||
|
||||
|
||||
async def _handle_command_interrupt(
|
||||
event_stream: EventStream, config: OpenHandsConfig
|
||||
) -> bool:
|
||||
"""Handle command interruption with user confirmation.
|
||||
|
||||
Returns:
|
||||
bool: True if the interrupt was handled, False if the user wants to pause the agent
|
||||
"""
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>Command is currently running.</gold>'))
|
||||
print_formatted_text('')
|
||||
|
||||
# Keep legacy behavior: single Ctrl+C pauses by default. Offer kill as opt-in.
|
||||
choices = [
|
||||
'Pause the agent (default)',
|
||||
'Continue waiting for command to complete',
|
||||
'Send interrupt to running command (Ctrl+C)',
|
||||
]
|
||||
|
||||
# Use the passed-in config so we honor CLI settings like VI mode. Run the blocking UI off the loop.
|
||||
selection = await asyncio.to_thread(
|
||||
cli_confirm, config, 'What would you like to do?', choices, 0
|
||||
)
|
||||
|
||||
if selection == 2: # Send interrupt to the running command
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<gold>Sending interrupt signal to running command...</gold>')
|
||||
)
|
||||
# Debounce rapid interrupts to avoid multiple concurrent dialogs/interrupts
|
||||
if _interrupt_lock.locked():
|
||||
print_formatted_text(HTML('<grey>Interrupt already sent; waiting…</grey>'))
|
||||
return True
|
||||
async with _interrupt_lock:
|
||||
event_stream.add_event(
|
||||
CmdRunAction(command='C-c', is_input=True),
|
||||
EventSource.USER,
|
||||
)
|
||||
return True
|
||||
elif selection == 1: # Continue waiting
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<gold>Continuing to wait for command completion...</gold>')
|
||||
)
|
||||
return True
|
||||
else: # Pause the agent (selection == 0)
|
||||
return False
|
||||
|
||||
|
||||
async def _handle_interrupt_async(
|
||||
event_stream: EventStream, done: asyncio.Event, config: OpenHandsConfig
|
||||
) -> None:
|
||||
"""Handle the interrupt asynchronously to avoid blocking the input handler."""
|
||||
try:
|
||||
handled = await _handle_command_interrupt(event_stream, config)
|
||||
if not handled:
|
||||
# User chose to pause the agent
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.PAUSED),
|
||||
EventSource.USER,
|
||||
)
|
||||
done.set()
|
||||
except Exception as e:
|
||||
# If something goes wrong, fall back to pausing the agent
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML(f'<ansired>Error handling interrupt: {e}</ansired>'))
|
||||
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.PAUSED),
|
||||
EventSource.USER,
|
||||
)
|
||||
done.set()
|
||||
|
||||
|
||||
async def process_agent_pause(
|
||||
done: asyncio.Event, event_stream: EventStream, config: OpenHandsConfig
|
||||
) -> None:
|
||||
async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) -> None:
|
||||
input = create_input()
|
||||
|
||||
# Double-press detection window for Ctrl+C to send interrupt to running command
|
||||
CTRL_C_WINDOW_SECONDS = 0.4
|
||||
ctrl_c_timer: asyncio.Task | None = None
|
||||
|
||||
async def pause_after_delay(delay: float) -> None:
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.PAUSED),
|
||||
EventSource.USER,
|
||||
)
|
||||
done.set()
|
||||
except asyncio.CancelledError:
|
||||
# Timer canceled because a second Ctrl+C was detected; do nothing
|
||||
pass
|
||||
|
||||
def keys_ready() -> None:
|
||||
nonlocal ctrl_c_timer
|
||||
for key_press in input.read_keys():
|
||||
if key_press.key == Keys.ControlP or key_press.key == Keys.ControlD:
|
||||
# Immediate pause
|
||||
if (
|
||||
key_press.key == Keys.ControlP
|
||||
or key_press.key == Keys.ControlC
|
||||
or key_press.key == Keys.ControlD
|
||||
):
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
|
||||
event_stream.add_event(
|
||||
@@ -1032,47 +900,6 @@ async def process_agent_pause(
|
||||
EventSource.USER,
|
||||
)
|
||||
done.set()
|
||||
elif key_press.key == Keys.ControlC:
|
||||
if is_command_running(event_stream):
|
||||
# If a timer is already running, this is a double-press: send interrupt
|
||||
if ctrl_c_timer and not ctrl_c_timer.done():
|
||||
ctrl_c_timer.cancel()
|
||||
ctrl_c_timer = None
|
||||
if _interrupt_lock.locked():
|
||||
print_formatted_text(
|
||||
HTML('<grey>Interrupt already sent; waiting…</grey>')
|
||||
)
|
||||
continue
|
||||
|
||||
# Send Ctrl+C to the running command
|
||||
async def send_interrupt() -> None:
|
||||
async with _interrupt_lock:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<gold>Sending interrupt signal to running command...</gold>'
|
||||
)
|
||||
)
|
||||
event_stream.add_event(
|
||||
CmdRunAction(command='C-c', is_input=True),
|
||||
EventSource.USER,
|
||||
)
|
||||
|
||||
asyncio.create_task(send_interrupt())
|
||||
else:
|
||||
# Start a short window; if no second press, pause
|
||||
ctrl_c_timer = asyncio.create_task(
|
||||
pause_after_delay(CTRL_C_WINDOW_SECONDS)
|
||||
)
|
||||
else:
|
||||
# No command running: default immediate pause
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.PAUSED),
|
||||
EventSource.USER,
|
||||
)
|
||||
done.set()
|
||||
|
||||
try:
|
||||
with input.raw_mode():
|
||||
|
||||
@@ -109,7 +109,7 @@ class AgentController:
|
||||
self,
|
||||
agent: Agent,
|
||||
event_stream: EventStream,
|
||||
conversation_stats: ConversationStats,
|
||||
convo_stats: ConversationStats,
|
||||
iteration_delta: int,
|
||||
budget_per_task_delta: float | None = None,
|
||||
agent_to_llm_config: dict[str, LLMConfig] | None = None,
|
||||
@@ -149,7 +149,7 @@ class AgentController:
|
||||
self.agent = agent
|
||||
self.headless_mode = headless_mode
|
||||
self.is_delegate = is_delegate
|
||||
self.conversation_stats = conversation_stats
|
||||
self.convo_stats = convo_stats
|
||||
|
||||
# the event stream must be set before maybe subscribing to it
|
||||
self.event_stream = event_stream
|
||||
@@ -165,7 +165,7 @@ class AgentController:
|
||||
# state from the previous session, state from a parent agent, or a fresh state
|
||||
self.set_initial_state(
|
||||
state=initial_state,
|
||||
conversation_stats=conversation_stats,
|
||||
convo_stats=convo_stats,
|
||||
max_iterations=iteration_delta,
|
||||
max_budget_per_task=budget_per_task_delta,
|
||||
confirmation_mode=confirmation_mode,
|
||||
@@ -687,7 +687,7 @@ class AgentController:
|
||||
user_id=self.user_id,
|
||||
agent=delegate_agent,
|
||||
event_stream=self.event_stream,
|
||||
conversation_stats=self.conversation_stats,
|
||||
convo_stats=self.convo_stats,
|
||||
iteration_delta=self._initial_max_iterations,
|
||||
budget_per_task_delta=self._initial_max_budget_per_task,
|
||||
agent_to_llm_config=self.agent_to_llm_config,
|
||||
@@ -951,7 +951,7 @@ class AgentController:
|
||||
def set_initial_state(
|
||||
self,
|
||||
state: State | None,
|
||||
conversation_stats: ConversationStats,
|
||||
convo_stats: ConversationStats,
|
||||
max_iterations: int,
|
||||
max_budget_per_task: float | None,
|
||||
confirmation_mode: bool = False,
|
||||
@@ -959,7 +959,7 @@ class AgentController:
|
||||
self.state_tracker.set_initial_state(
|
||||
self.id,
|
||||
state,
|
||||
conversation_stats,
|
||||
convo_stats,
|
||||
max_iterations,
|
||||
max_budget_per_task,
|
||||
confirmation_mode,
|
||||
@@ -1000,7 +1000,7 @@ class AgentController:
|
||||
action: The action to attach metrics to
|
||||
"""
|
||||
# Get metrics from agent LLM
|
||||
metrics = self.conversation_stats.get_combined_metrics()
|
||||
metrics = self.convo_stats.get_combined_metrics()
|
||||
|
||||
# Create a clean copy with only the fields we want to keep
|
||||
clean_metrics = Metrics()
|
||||
|
||||
@@ -85,7 +85,7 @@ class State:
|
||||
limit_increase_amount=100, current_value=0, max_value=100
|
||||
)
|
||||
)
|
||||
conversation_stats: ConversationStats | None = None
|
||||
convo_stats: ConversationStats | None = None
|
||||
budget_flag: BudgetControlFlag | None = None
|
||||
confirmation_mode: bool = False
|
||||
history: list[Event] = field(default_factory=list)
|
||||
@@ -122,8 +122,8 @@ class State:
|
||||
def save_to_session(
|
||||
self, sid: str, file_store: FileStore, user_id: str | None
|
||||
) -> None:
|
||||
conversation_stats = self.conversation_stats
|
||||
self.conversation_stats = None # Don't save conversation stats, handles itself
|
||||
convo_stats = self.convo_stats
|
||||
self.convo_stats = None # Don't save convo stats, handles itself
|
||||
|
||||
pickled = pickle.dumps(self)
|
||||
logger.debug(f'Saving state to session {sid}:{self.agent_state}')
|
||||
@@ -144,7 +144,7 @@ class State:
|
||||
logger.error(f'Failed to save state to session: {e}')
|
||||
raise e
|
||||
|
||||
self.conversation_stats = conversation_stats # restore reference
|
||||
self.convo_stats = convo_stats # restore reference
|
||||
|
||||
@staticmethod
|
||||
def restore_from_session(
|
||||
|
||||
@@ -51,7 +51,7 @@ class StateTracker:
|
||||
self,
|
||||
id: str,
|
||||
state: State | None,
|
||||
conversation_stats: ConversationStats,
|
||||
convo_stats: ConversationStats,
|
||||
max_iterations: int,
|
||||
max_budget_per_task: float | None,
|
||||
confirmation_mode: bool = False,
|
||||
@@ -74,7 +74,7 @@ class StateTracker:
|
||||
session_id=id.removesuffix('-delegate'),
|
||||
user_id=self.user_id,
|
||||
inputs={},
|
||||
conversation_stats=conversation_stats,
|
||||
convo_stats=convo_stats,
|
||||
iteration_flag=IterationControlFlag(
|
||||
limit_increase_amount=max_iterations,
|
||||
current_value=0,
|
||||
@@ -99,7 +99,7 @@ class StateTracker:
|
||||
if self.state.start_id <= -1:
|
||||
self.state.start_id = 0
|
||||
|
||||
state.conversation_stats = conversation_stats
|
||||
state.convo_stats = convo_stats
|
||||
|
||||
def _init_history(self, event_stream: EventStream) -> None:
|
||||
"""Initializes the agent's history from the event stream.
|
||||
@@ -248,8 +248,8 @@ class StateTracker:
|
||||
if self.sid and self.file_store:
|
||||
self.state.save_to_session(self.sid, self.file_store, self.user_id)
|
||||
|
||||
if self.state.conversation_stats:
|
||||
self.state.conversation_stats.save_metrics()
|
||||
if self.state.convo_stats:
|
||||
self.state.convo_stats.save_metrics()
|
||||
|
||||
def run_control_flags(self):
|
||||
"""Performs one step of the control flags"""
|
||||
@@ -262,7 +262,7 @@ class StateTracker:
|
||||
Budget flag will monitor for when budget is exceeded
|
||||
"""
|
||||
# Sync cost across all llm services from llm registry
|
||||
if self.state.budget_flag and self.state.conversation_stats:
|
||||
if self.state.budget_flag and self.state.convo_stats:
|
||||
self.state.budget_flag.current_value = (
|
||||
self.state.conversation_stats.get_combined_metrics().accumulated_cost
|
||||
self.state.convo_stats.get_combined_metrics().accumulated_cost
|
||||
)
|
||||
|
||||
@@ -172,6 +172,9 @@ class LLMConfig(BaseModel):
|
||||
|
||||
# Set reasoning_effort to 'high' by default for non-Gemini models
|
||||
# Gemini models use optimized thinking budget when reasoning_effort is None
|
||||
logger.debug(
|
||||
f'Setting reasoning_effort for model {self.model} with reasoning_effort {self.reasoning_effort}'
|
||||
)
|
||||
if self.reasoning_effort is None and 'gemini-2.5-pro' not in self.model:
|
||||
self.reasoning_effort = 'high'
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ class SandboxConfig(BaseModel):
|
||||
remote_runtime_enable_retries: Whether to enable retries (on recoverable errors like requests.ConnectionError) for the remote runtime API requests.
|
||||
enable_auto_lint: Whether to enable auto-lint.
|
||||
use_host_network: Whether to use the host network.
|
||||
additional_networks: A list of additional Docker networks to connect to
|
||||
runtime_binding_address: The binding address for the runtime ports. It specifies which network interface on the host machine Docker should bind the runtime ports to.
|
||||
initialize_plugins: Whether to initialize plugins.
|
||||
force_rebuild_runtime: Whether to force rebuild the runtime image.
|
||||
@@ -66,7 +65,6 @@ class SandboxConfig(BaseModel):
|
||||
default=False
|
||||
) # once enabled, OpenHands would lint files after editing
|
||||
use_host_network: bool = Field(default=False)
|
||||
additional_networks: list[str] = Field(default=[])
|
||||
runtime_binding_address: str = Field(default='0.0.0.0')
|
||||
runtime_extra_build_args: list[str] | None = Field(default=None)
|
||||
initialize_plugins: bool = Field(default=True)
|
||||
|
||||
@@ -32,11 +32,12 @@ from openhands.events.action.action import Action
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import AgentStateChangedObservation
|
||||
from openhands.io import read_input, read_task
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.mcp import add_mcp_tools_to_agent
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
from openhands.utils.utils import create_registry_and_convo_stats
|
||||
|
||||
|
||||
class FakeUserResponseFunc(Protocol):
|
||||
@@ -58,6 +59,7 @@ async def run_controller(
|
||||
headless_mode: bool = True,
|
||||
memory: Memory | None = None,
|
||||
conversation_instructions: str | None = None,
|
||||
llm_registry: LLMRegistry | None = None,
|
||||
) -> State | None:
|
||||
"""Main coroutine to run the agent controller with task input flexibility.
|
||||
|
||||
@@ -96,7 +98,7 @@ async def run_controller(
|
||||
"""
|
||||
sid = sid or generate_sid(config)
|
||||
|
||||
llm_registry, conversation_stats, config = create_registry_and_conversation_stats(
|
||||
llm_registry, convo_stats, config = create_registry_and_convo_stats(
|
||||
config,
|
||||
sid,
|
||||
None,
|
||||
@@ -163,7 +165,7 @@ async def run_controller(
|
||||
)
|
||||
|
||||
controller, initial_state = create_controller(
|
||||
agent, runtime, config, conversation_stats, replay_events=replay_events
|
||||
agent, runtime, config, convo_stats, replay_events=replay_events
|
||||
)
|
||||
|
||||
assert isinstance(initial_user_action, Action), (
|
||||
|
||||
@@ -35,7 +35,7 @@ from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
|
||||
|
||||
def create_runtime(
|
||||
config: OpenHandsConfig,
|
||||
llm_registry: LLMRegistry | None = None,
|
||||
llm_registry: LLMRegistry,
|
||||
sid: str | None = None,
|
||||
headless_mode: bool = True,
|
||||
agent: Agent | None = None,
|
||||
@@ -84,7 +84,7 @@ def create_runtime(
|
||||
sid=session_id,
|
||||
plugins=agent_cls.sandbox_plugins,
|
||||
headless_mode=headless_mode,
|
||||
llm_registry=llm_registry or LLMRegistry(config),
|
||||
llm_registry=llm_registry,
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
)
|
||||
|
||||
@@ -218,7 +218,7 @@ def create_controller(
|
||||
agent: Agent,
|
||||
runtime: Runtime,
|
||||
config: OpenHandsConfig,
|
||||
conversation_stats: ConversationStats,
|
||||
convo_stats: ConversationStats,
|
||||
headless_mode: bool = True,
|
||||
replay_events: list[Event] | None = None,
|
||||
) -> tuple[AgentController, State | None]:
|
||||
@@ -236,7 +236,7 @@ def create_controller(
|
||||
|
||||
controller = AgentController(
|
||||
agent=agent,
|
||||
conversation_stats=conversation_stats,
|
||||
convo_stats=convo_stats,
|
||||
iteration_delta=config.max_iterations,
|
||||
budget_per_task_delta=config.max_budget_per_task,
|
||||
agent_to_llm_config=config.get_agent_to_llm_config_map(),
|
||||
|
||||
@@ -321,36 +321,6 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
installations = response.get('installations', [])
|
||||
return [str(i['id']) for i in installations]
|
||||
|
||||
async def get_user_organizations(self) -> list[str]:
|
||||
"""Get list of organization logins that the user is a member of."""
|
||||
url = f'{self.BASE_URL}/user/orgs'
|
||||
try:
|
||||
response, _ = await self._make_request(url)
|
||||
orgs = [org['login'] for org in response]
|
||||
return orgs
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get user organizations: {e}')
|
||||
return []
|
||||
|
||||
def _fuzzy_match_org_name(self, query: str, org_name: str) -> bool:
|
||||
"""Check if query fuzzy matches organization name."""
|
||||
query_lower = query.lower().replace('-', '').replace('_', '').replace(' ', '')
|
||||
org_lower = org_name.lower().replace('-', '').replace('_', '').replace(' ', '')
|
||||
|
||||
# Exact match after normalization
|
||||
if query_lower == org_lower:
|
||||
return True
|
||||
|
||||
# Query is a substring of org name
|
||||
if query_lower in org_lower:
|
||||
return True
|
||||
|
||||
# Org name is a substring of query (less common but possible)
|
||||
if org_lower in query_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
) -> list[Repository]:
|
||||
@@ -371,68 +341,21 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
# Add is:public to the query to ensure we only search for public repositories
|
||||
params['q'] = f'in:name {org}/{repo_name} is:public'
|
||||
|
||||
# Handle private repository searches
|
||||
# Perhaps we should go through all orgs and the search for repos under every org
|
||||
# Currently it will only search user repos, and org repos when '/' is in the name
|
||||
if not public and '/' in query:
|
||||
org, repo_query = query.split('/', 1)
|
||||
query_with_user = f'org:{org} in:name {repo_query}'
|
||||
params['q'] = query_with_user
|
||||
elif not public:
|
||||
# Expand search scope to include user's repositories and organizations they're a member of
|
||||
user = await self.get_user()
|
||||
user_orgs = await self.get_user_organizations()
|
||||
params['q'] = f'in:name {query} user:{user.login}'
|
||||
|
||||
# Search in user repos and org repos separately
|
||||
all_repos = []
|
||||
|
||||
# Search in user repositories
|
||||
user_query = f'{query} user:{user.login}'
|
||||
user_params = params.copy()
|
||||
user_params['q'] = user_query
|
||||
|
||||
try:
|
||||
user_response, _ = await self._make_request(url, user_params)
|
||||
user_items = user_response.get('items', [])
|
||||
all_repos.extend(user_items)
|
||||
except Exception as e:
|
||||
logger.warning(f'User search failed: {e}')
|
||||
|
||||
# Search for repos named "query" in each organization
|
||||
for org in user_orgs:
|
||||
org_query = f'{query} org:{org}'
|
||||
org_params = params.copy()
|
||||
org_params['q'] = org_query
|
||||
|
||||
try:
|
||||
org_response, _ = await self._make_request(url, org_params)
|
||||
org_items = org_response.get('items', [])
|
||||
all_repos.extend(org_items)
|
||||
except Exception as e:
|
||||
logger.warning(f'Org {org} search failed: {e}')
|
||||
|
||||
# Also search for top repos from orgs that match the query name
|
||||
for org in user_orgs:
|
||||
if self._fuzzy_match_org_name(query, org):
|
||||
org_repos_query = f'org:{org}'
|
||||
org_repos_params = params.copy()
|
||||
org_repos_params['q'] = org_repos_query
|
||||
org_repos_params['sort'] = 'stars'
|
||||
org_repos_params['per_page'] = 2 # Limit to first 2 repos
|
||||
|
||||
try:
|
||||
org_repos_response, _ = await self._make_request(
|
||||
url, org_repos_params
|
||||
)
|
||||
org_repo_items = org_repos_response.get('items', [])
|
||||
all_repos.extend(org_repo_items)
|
||||
except Exception as e:
|
||||
logger.warning(f'Org repos search for {org} failed: {e}')
|
||||
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
|
||||
# Default case (public search or slash query)
|
||||
response, _ = await self._make_request(url, params)
|
||||
repo_items = response.get('items', [])
|
||||
return [self._parse_repository(repo) for repo in repo_items]
|
||||
repos = [self._parse_repository(repo) for repo in repo_items]
|
||||
|
||||
return repos
|
||||
|
||||
async def execute_graphql_query(
|
||||
self, query: str, variables: dict[str, Any]
|
||||
|
||||
+872
-782
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import * as path from "path";
|
||||
import Mocha = require("mocha");
|
||||
import { glob } from "glob"; // Updated for glob v9+ API
|
||||
import Mocha = require("mocha"); // Changed import style
|
||||
import glob = require("glob"); // Changed import style
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
export function run(): Promise<void> {
|
||||
// Create the mocha test
|
||||
const mocha = new Mocha({
|
||||
// This should now work with the changed import
|
||||
@@ -13,25 +13,33 @@ export async function run(): Promise<void> {
|
||||
|
||||
const testsRoot = path.resolve(__dirname, ".."); // Root of the /src/test folder (compiled to /out/test)
|
||||
|
||||
try {
|
||||
return new Promise((c, e) => {
|
||||
// Use glob to find all test files (ending with .test.js in the compiled output)
|
||||
const files = await glob("**/**.test.js", { cwd: testsRoot });
|
||||
|
||||
// Add files to the test suite
|
||||
files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f)));
|
||||
|
||||
// Run the mocha test
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
mocha.run((failures: number) => {
|
||||
if (failures > 0) {
|
||||
reject(new Error(`${failures} tests failed.`));
|
||||
} else {
|
||||
resolve();
|
||||
glob(
|
||||
"**/**.test.js",
|
||||
{ cwd: testsRoot },
|
||||
(err: NodeJS.ErrnoException | null, files: string[]) => {
|
||||
if (err) {
|
||||
return e(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Add files to the test suite
|
||||
files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f)));
|
||||
|
||||
try {
|
||||
// Run the mocha test
|
||||
mocha.run((failures: number) => {
|
||||
if (failures > 0) {
|
||||
e(new Error(`${failures} tests failed.`));
|
||||
} else {
|
||||
c();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
e(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.llm.llm import (
|
||||
LLM,
|
||||
LLM_RETRY_EXCEPTIONS,
|
||||
REASONING_EFFORT_SUPPORTED_MODELS,
|
||||
)
|
||||
from openhands.llm.model_features import get_features
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class AsyncLLM(LLM):
|
||||
messages = kwargs['messages']
|
||||
|
||||
# Set reasoning effort for models that support it
|
||||
if get_features(self.config.model).supports_reasoning_effort:
|
||||
if self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS:
|
||||
kwargs['reasoning_effort'] = self.config.reasoning_effort
|
||||
|
||||
# ensure we work with a list of messages
|
||||
|
||||
@@ -705,25 +705,6 @@ def _fix_stopword(content: str) -> str:
|
||||
return content
|
||||
|
||||
|
||||
def _normalize_parameter_tags(fn_body: str) -> str:
|
||||
"""Normalize malformed parameter tags to the canonical format.
|
||||
|
||||
Some models occasionally emit malformed parameter tags like:
|
||||
<parameter=command=str_replace</parameter>
|
||||
instead of the correct:
|
||||
<parameter=command>str_replace</parameter>
|
||||
|
||||
This function rewrites the malformed form into the correct one to allow
|
||||
downstream parsing to succeed.
|
||||
"""
|
||||
# Replace '<parameter=name=value</parameter>' with '<parameter=name>value</parameter>'
|
||||
return re.sub(
|
||||
r'<parameter=([a-zA-Z0-9_]+)=([^<]*)</parameter>',
|
||||
r'<parameter=\1>\2</parameter>',
|
||||
fn_body,
|
||||
)
|
||||
|
||||
|
||||
def convert_non_fncall_messages_to_fncall_messages(
|
||||
messages: list[dict],
|
||||
tools: list[ChatCompletionToolParam],
|
||||
@@ -871,7 +852,7 @@ def convert_non_fncall_messages_to_fncall_messages(
|
||||
|
||||
if fn_match:
|
||||
fn_name = fn_match.group(1)
|
||||
fn_body = _normalize_parameter_tags(fn_match.group(2))
|
||||
fn_body = fn_match.group(2)
|
||||
matching_tool = next(
|
||||
(
|
||||
tool['function']
|
||||
|
||||
+96
-28
@@ -9,7 +9,6 @@ import httpx
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.llm.model_features import get_features
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
@@ -50,6 +49,79 @@ LLM_RETRY_EXCEPTIONS: tuple[type[Exception], ...] = (
|
||||
LLMNoResponseError,
|
||||
)
|
||||
|
||||
# cache prompt supporting models
|
||||
# remove this when we gemini and deepseek are supported
|
||||
CACHE_PROMPT_SUPPORTED_MODELS = [
|
||||
'claude-3-7-sonnet-20250219',
|
||||
'claude-sonnet-3-7-latest',
|
||||
'claude-3.7-sonnet',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-sonnet-20240620',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-3-haiku-20240307',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
]
|
||||
|
||||
# function calling supporting models
|
||||
FUNCTION_CALLING_SUPPORTED_MODELS = [
|
||||
'claude-3-7-sonnet-20250219',
|
||||
'claude-sonnet-3-7-latest',
|
||||
'claude-3-5-sonnet',
|
||||
'claude-3-5-sonnet-20240620',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3.5-haiku',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4o',
|
||||
'o1-2024-12-17',
|
||||
'o3-mini-2025-01-31',
|
||||
'o3-mini',
|
||||
'o3',
|
||||
'o3-2025-04-16',
|
||||
'o4-mini',
|
||||
'o4-mini-2025-04-16',
|
||||
'gemini-2.5-pro',
|
||||
'gpt-4.1',
|
||||
'kimi-k2-0711-preview',
|
||||
'kimi-k2-instruct',
|
||||
'Qwen3-Coder-480B-A35B-Instruct',
|
||||
'qwen3-coder', # this will match both qwen3-coder-480b (openhands provider) and qwen3-coder (for openrouter)
|
||||
'gpt-5',
|
||||
'gpt-5-2025-08-07',
|
||||
]
|
||||
|
||||
REASONING_EFFORT_SUPPORTED_MODELS = [
|
||||
'o1-2024-12-17',
|
||||
'o1',
|
||||
'o3',
|
||||
'o3-2025-04-16',
|
||||
'o3-mini-2025-01-31',
|
||||
'o3-mini',
|
||||
'o4-mini',
|
||||
'o4-mini-2025-04-16',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gpt-5',
|
||||
'gpt-5-2025-08-07',
|
||||
'claude-opus-4-1-20250805', # we need to remove top_p for opus 4.1
|
||||
]
|
||||
|
||||
MODELS_WITHOUT_STOP_WORDS = [
|
||||
'o1-mini',
|
||||
'o1-preview',
|
||||
'o1',
|
||||
'o1-2024-12-17',
|
||||
'xai/grok-4-0709',
|
||||
]
|
||||
|
||||
|
||||
class LLM(RetryMixin, DebugMixin):
|
||||
"""The LLM class represents a Language Model instance.
|
||||
@@ -82,7 +154,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
)
|
||||
|
||||
self.model_info: ModelInfo | None = None
|
||||
self._function_calling_active: bool = False
|
||||
self.retry_listener = retry_listener
|
||||
if self.config.log_completions:
|
||||
if self.config.log_completions_folder is None:
|
||||
@@ -131,8 +202,10 @@ class LLM(RetryMixin, DebugMixin):
|
||||
f'Rewrote openhands/{model_name} to {self.config.model} with base URL {self.config.base_url}'
|
||||
)
|
||||
|
||||
features = get_features(self.config.model)
|
||||
if features.supports_reasoning_effort:
|
||||
if (
|
||||
self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS
|
||||
or self.config.model.split('/')[-1] in REASONING_EFFORT_SUPPORTED_MODELS
|
||||
):
|
||||
# For Gemini models, only map 'low' to optimized thinking budget
|
||||
# Let other reasoning_effort values pass through to API as-is
|
||||
if 'gemini-2.5-pro' in self.config.model:
|
||||
@@ -166,20 +239,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
elif 'gemini' in self.config.model.lower() and self.config.safety_settings:
|
||||
kwargs['safety_settings'] = self.config.safety_settings
|
||||
|
||||
# Explicitly disable Anthropic extended thinking for Opus 4.1 to avoid
|
||||
# requiring 'thinking' content blocks. See issue #10510.
|
||||
if 'claude-opus-4-1' in self.config.model.lower():
|
||||
kwargs['thinking'] = {'type': 'disabled'}
|
||||
|
||||
# Anthropic constraint: Opus models cannot accept both temperature and top_p
|
||||
# Prefer temperature (drop top_p) if both are specified.
|
||||
_model_lower = self.config.model.lower()
|
||||
# Limit to Opus 4.1 specifically to avoid changing behavior of other Anthropic models
|
||||
if ('claude-opus-4-1' in _model_lower) and (
|
||||
'temperature' in kwargs and 'top_p' in kwargs
|
||||
):
|
||||
kwargs.pop('top_p', None)
|
||||
|
||||
self._completion = partial(
|
||||
litellm_completion,
|
||||
model=self.config.model,
|
||||
@@ -253,7 +312,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
|
||||
# add stop words if the model supports it and stop words are not disabled
|
||||
if (
|
||||
get_features(self.config.model).supports_stop_words
|
||||
self.config.model not in MODELS_WITHOUT_STOP_WORDS
|
||||
and not self.config.disable_stop_word
|
||||
):
|
||||
kwargs['stop'] = STOP_WORDS
|
||||
@@ -497,10 +556,17 @@ class LLM(RetryMixin, DebugMixin):
|
||||
):
|
||||
self.config.max_output_tokens = self.model_info['max_tokens']
|
||||
|
||||
# Initialize function calling using centralized model features
|
||||
features = get_features(self.config.model)
|
||||
# Initialize function calling capability
|
||||
# Check if model name is in our supported list
|
||||
model_name_supported = (
|
||||
self.config.model in FUNCTION_CALLING_SUPPORTED_MODELS
|
||||
or self.config.model.split('/')[-1] in FUNCTION_CALLING_SUPPORTED_MODELS
|
||||
or any(m in self.config.model for m in FUNCTION_CALLING_SUPPORTED_MODELS)
|
||||
)
|
||||
|
||||
# Handle native_tool_calling user-defined configuration
|
||||
if self.config.native_tool_calling is None:
|
||||
self._function_calling_active = features.supports_function_calling
|
||||
self._function_calling_active = model_name_supported
|
||||
else:
|
||||
self._function_calling_active = self.config.native_tool_calling
|
||||
|
||||
@@ -535,10 +601,14 @@ class LLM(RetryMixin, DebugMixin):
|
||||
Returns:
|
||||
boolean: True if prompt caching is supported and enabled for the given model.
|
||||
"""
|
||||
if not self.config.caching_prompt:
|
||||
return False
|
||||
# We don't need to look-up model_info, because only Anthropic models need explicit caching breakpoints
|
||||
return get_features(self.config.model).supports_prompt_cache
|
||||
return (
|
||||
self.config.caching_prompt is True
|
||||
and (
|
||||
self.config.model in CACHE_PROMPT_SUPPORTED_MODELS
|
||||
or self.config.model.split('/')[-1] in CACHE_PROMPT_SUPPORTED_MODELS
|
||||
)
|
||||
# We don't need to look-up model_info, because only Anthropic models needs the explicit caching breakpoint
|
||||
)
|
||||
|
||||
def is_function_calling_active(self) -> bool:
|
||||
"""Returns whether function calling is supported and enabled for this LLM instance.
|
||||
@@ -780,8 +850,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
message.force_string_serializer = True
|
||||
if 'kimi-k2-instruct' in self.config.model and 'groq' in self.config.model:
|
||||
message.force_string_serializer = True
|
||||
if 'openrouter/anthropic/claude-sonnet-4' in self.config.model:
|
||||
message.force_string_serializer = True
|
||||
|
||||
# let pydantic handle the serialization
|
||||
return [message.model_dump() for message in messages]
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
|
||||
|
||||
def normalize_model_name(model: str) -> str:
|
||||
"""Normalize a model string to a canonical, comparable name.
|
||||
|
||||
Strategy:
|
||||
- Trim whitespace
|
||||
- Lowercase
|
||||
- If there is a '/', keep only the basename after the last '/'
|
||||
(handles prefixes like openrouter/, litellm_proxy/, anthropic/, etc.)
|
||||
and treat ':' inside that basename as an Ollama-style variant tag to be removed
|
||||
- There is no provider:model form; providers, when present, use 'provider/model'
|
||||
- Drop a trailing "-gguf" suffix if present
|
||||
"""
|
||||
raw = (model or '').strip().lower()
|
||||
if '/' in raw:
|
||||
name = raw.split('/')[-1]
|
||||
if ':' in name:
|
||||
# Drop Ollama-style variant tag in basename
|
||||
name = name.split(':', 1)[0]
|
||||
else:
|
||||
# No '/', keep the whole raw name (we do not support provider:model)
|
||||
name = raw
|
||||
if name.endswith('-gguf'):
|
||||
name = name[: -len('-gguf')]
|
||||
return name
|
||||
|
||||
|
||||
def model_matches(model: str, patterns: list[str]) -> bool:
|
||||
"""Return True if the model matches any of the glob patterns.
|
||||
|
||||
If a pattern contains a '/', it is treated as provider-qualified and matched
|
||||
against the full, lowercased model string (including provider prefix).
|
||||
Otherwise, it is matched against the normalized basename.
|
||||
"""
|
||||
raw = (model or '').strip().lower()
|
||||
name = normalize_model_name(model)
|
||||
for pat in patterns:
|
||||
pat_l = pat.lower()
|
||||
if '/' in pat_l:
|
||||
if fnmatch(raw, pat_l):
|
||||
return True
|
||||
else:
|
||||
if fnmatch(name, pat_l):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelFeatures:
|
||||
supports_function_calling: bool
|
||||
supports_reasoning_effort: bool
|
||||
supports_prompt_cache: bool
|
||||
supports_stop_words: bool
|
||||
|
||||
|
||||
# Pattern tables capturing current behavior. Keep patterns lowercase.
|
||||
FUNCTION_CALLING_PATTERNS: list[str] = [
|
||||
# Anthropic families
|
||||
'claude-3-7-sonnet*',
|
||||
'claude-3.7-sonnet*',
|
||||
'claude-sonnet-3-7-latest',
|
||||
'claude-3-5-sonnet*',
|
||||
'claude-3.5-haiku*',
|
||||
'claude-3-5-haiku*',
|
||||
'claude-sonnet-4*',
|
||||
'claude-opus-4*',
|
||||
# OpenAI families
|
||||
'gpt-4o*',
|
||||
'gpt-4.1',
|
||||
'gpt-5*',
|
||||
# o-series (keep exact o1 support per existing list)
|
||||
'o1-2024-12-17',
|
||||
'o3*',
|
||||
'o4-mini*',
|
||||
# Google Gemini
|
||||
'gemini-2.5-pro*',
|
||||
# Others
|
||||
'kimi-k2-0711-preview',
|
||||
'kimi-k2-instruct',
|
||||
'qwen3-coder*',
|
||||
'qwen3-coder-480b-a35b-instruct',
|
||||
]
|
||||
|
||||
REASONING_EFFORT_PATTERNS: list[str] = [
|
||||
# Mirror main behavior exactly (no unintended expansion), plus DeepSeek support
|
||||
'o1-2024-12-17',
|
||||
'o1',
|
||||
'o3',
|
||||
'o3-2025-04-16',
|
||||
'o3-mini-2025-01-31',
|
||||
'o3-mini',
|
||||
'o4-mini',
|
||||
'o4-mini-2025-04-16',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gpt-5',
|
||||
'gpt-5-2025-08-07',
|
||||
# DeepSeek reasoning family
|
||||
'deepseek-r1-0528*',
|
||||
]
|
||||
|
||||
PROMPT_CACHE_PATTERNS: list[str] = [
|
||||
'claude-3-7-sonnet*',
|
||||
'claude-3.7-sonnet*',
|
||||
'claude-sonnet-3-7-latest',
|
||||
'claude-3-5-sonnet*',
|
||||
'claude-3-5-haiku*',
|
||||
'claude-3.5-haiku*',
|
||||
'claude-3-haiku-20240307',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-sonnet-4*',
|
||||
'claude-opus-4*',
|
||||
]
|
||||
|
||||
SUPPORTS_STOP_WORDS_FALSE_PATTERNS: list[str] = [
|
||||
# o1 family doesn't support stop words
|
||||
'o1*',
|
||||
# grok-4 specific model name (basename)
|
||||
'grok-4-0709',
|
||||
# DeepSeek R1 family
|
||||
'deepseek-r1-0528*',
|
||||
]
|
||||
|
||||
|
||||
def get_features(model: str) -> ModelFeatures:
|
||||
return ModelFeatures(
|
||||
supports_function_calling=model_matches(model, FUNCTION_CALLING_PATTERNS),
|
||||
supports_reasoning_effort=model_matches(model, REASONING_EFFORT_PATTERNS),
|
||||
supports_prompt_cache=model_matches(model, PROMPT_CACHE_PATTERNS),
|
||||
supports_stop_words=not model_matches(
|
||||
model, SUPPORTS_STOP_WORDS_FALSE_PATTERNS
|
||||
),
|
||||
)
|
||||
@@ -5,7 +5,7 @@ from typing import Any, Callable
|
||||
from openhands.core.exceptions import UserCancelledError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.llm.async_llm import LLM_RETRY_EXCEPTIONS, AsyncLLM
|
||||
from openhands.llm.model_features import get_features
|
||||
from openhands.llm.llm import REASONING_EFFORT_SUPPORTED_MODELS
|
||||
|
||||
|
||||
class StreamingLLM(AsyncLLM):
|
||||
@@ -65,7 +65,7 @@ class StreamingLLM(AsyncLLM):
|
||||
)
|
||||
|
||||
# Set reasoning effort for models that support it
|
||||
if get_features(self.config.model).supports_reasoning_effort:
|
||||
if self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS:
|
||||
kwargs['reasoning_effort'] = self.config.reasoning_effort
|
||||
|
||||
self.log_prompt(messages)
|
||||
|
||||
+18
-31
@@ -67,7 +67,6 @@ from openhands.runtime.plugins import (
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils.edit import FileEditRuntimeMixin
|
||||
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
||||
from openhands.storage.locations import get_conversation_dir
|
||||
from openhands.utils.async_utils import (
|
||||
GENERAL_TIMEOUT,
|
||||
call_async_from_sync,
|
||||
@@ -877,14 +876,8 @@ fi
|
||||
if isinstance(action, AgentThinkAction):
|
||||
return AgentThinkObservation('Your thought has been logged.')
|
||||
elif isinstance(action, TaskTrackingAction):
|
||||
# Get the session-specific task file path
|
||||
conversation_dir = get_conversation_dir(
|
||||
self.sid, self.event_stream.user_id
|
||||
)
|
||||
task_file_path = f'{conversation_dir}TASKS.md'
|
||||
|
||||
# If `command` is `plan`, write the serialized task list to the file TASKS.md under `.openhands/`
|
||||
if action.command == 'plan':
|
||||
# Write the serialized task list to the session directory
|
||||
content = '# Task List\n\n'
|
||||
for i, task in enumerate(action.task_list, 1):
|
||||
status_icon = {
|
||||
@@ -893,39 +886,33 @@ fi
|
||||
'done': '✅',
|
||||
}.get(task.get('status', 'todo'), '⏳')
|
||||
content += f'{i}. {status_icon} {task.get("title", "")}\n{task.get("notes", "")}\n'
|
||||
|
||||
try:
|
||||
self.event_stream.file_store.write(task_file_path, content)
|
||||
return TaskTrackingObservation(
|
||||
content=f'Task list has been updated with {len(action.task_list)} items. Stored in session directory: {task_file_path}',
|
||||
command=action.command,
|
||||
task_list=action.task_list,
|
||||
)
|
||||
except Exception as e:
|
||||
write_obs = self.write(
|
||||
FileWriteAction(path='.openhands/TASKS.md', content=content)
|
||||
)
|
||||
if isinstance(write_obs, ErrorObservation):
|
||||
return ErrorObservation(
|
||||
f'Failed to write task list to session directory {task_file_path}: {str(e)}'
|
||||
f'Failed to write task list to .openhands/TASKS.md: {write_obs.content}'
|
||||
)
|
||||
|
||||
return TaskTrackingObservation(
|
||||
content=f'Task list has been updated with {len(action.task_list)} items.',
|
||||
command=action.command,
|
||||
task_list=action.task_list,
|
||||
)
|
||||
elif action.command == 'view':
|
||||
# Read the TASKS.md file from the session directory
|
||||
try:
|
||||
content = self.event_stream.file_store.read(task_file_path)
|
||||
# If `command` is `view`, read the TASKS.md file and return its content
|
||||
read_obs = self.read(FileReadAction(path='.openhands/TASKS.md'))
|
||||
if isinstance(read_obs, FileReadObservation):
|
||||
return TaskTrackingObservation(
|
||||
content=content,
|
||||
content=read_obs.content,
|
||||
command=action.command,
|
||||
task_list=[], # Empty for view command
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return TaskTrackingObservation(
|
||||
else:
|
||||
return TaskTrackingObservation( # Return observation if error occurs because file might not exist yet
|
||||
command=action.command,
|
||||
task_list=[],
|
||||
content='No task list found. Use the "plan" command to create one.',
|
||||
)
|
||||
except Exception as e:
|
||||
return TaskTrackingObservation(
|
||||
command=action.command,
|
||||
task_list=[],
|
||||
content=f'Failed to read the task list from session directory {task_file_path}. Error: {str(e)}',
|
||||
content=f'Failed to read the task list. Error: {read_obs.content}',
|
||||
)
|
||||
|
||||
return NullObservation('')
|
||||
|
||||
@@ -213,23 +213,6 @@ class DockerRuntime(ActionExecutionClient):
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
self._runtime_initialized = True
|
||||
|
||||
for network_name in self.config.sandbox.additional_networks:
|
||||
try:
|
||||
network = self.docker_client.networks.get(network_name)
|
||||
if self.container is not None:
|
||||
network.connect(self.container)
|
||||
else:
|
||||
self.log(
|
||||
'warning',
|
||||
f'Container not available to connect to network {network_name}',
|
||||
)
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Error: Failed to connect instance {self.container_name} to network {network_name}',
|
||||
)
|
||||
self.log('error', str(e))
|
||||
|
||||
def maybe_build_runtime_container_image(self):
|
||||
if self.runtime_container_image is None:
|
||||
if self.base_container_image is None:
|
||||
|
||||
@@ -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.54-nikolaik"
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
@@ -201,14 +201,8 @@ class LocalRuntime(ActionExecutionClient):
|
||||
|
||||
# If there is an API key in the environment we use this in requests to the runtime
|
||||
session_api_key = os.getenv('SESSION_API_KEY')
|
||||
self._session_api_key: str | None = None
|
||||
if session_api_key:
|
||||
self.session.headers['X-Session-API-Key'] = session_api_key
|
||||
self._session_api_key = session_api_key
|
||||
|
||||
@property
|
||||
def session_api_key(self) -> str | None:
|
||||
return self._session_api_key
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self) -> str:
|
||||
|
||||
@@ -177,7 +177,9 @@ RUN \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry install --only main,runtime --no-interaction --no-root && \
|
||||
# Update and install additional tools
|
||||
# (There used to be an "apt-get update" here, hopefully we can skip it.)
|
||||
{% if enable_browser %}/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \{% endif %}
|
||||
{% if enable_browser %}
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
|
||||
{% endif %}
|
||||
# Set environment variables
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
|
||||
# Set permissions
|
||||
|
||||
@@ -42,7 +42,7 @@ from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import get_conversation_dir
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.import_utils import get_impl
|
||||
from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
from openhands.utils.utils import create_registry_and_convo_stats
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -486,14 +486,14 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
user_id, sid, self.config
|
||||
)
|
||||
|
||||
llm_registry, conversation_stats, config = (
|
||||
create_registry_and_conversation_stats(config, sid, user_id, settings)
|
||||
llm_registry, convo_stats, config = create_registry_and_convo_stats(
|
||||
config, sid, user_id, settings
|
||||
)
|
||||
|
||||
session = Session(
|
||||
sid=sid,
|
||||
llm_registry=llm_registry,
|
||||
conversation_stats=conversation_stats,
|
||||
convo_stats=convo_stats,
|
||||
file_store=self.file_store,
|
||||
config=config,
|
||||
sio=self.sio,
|
||||
|
||||
@@ -39,7 +39,7 @@ from openhands.utils.conversation_summary import (
|
||||
)
|
||||
from openhands.utils.import_utils import get_impl
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
from openhands.utils.utils import create_registry_and_convo_stats
|
||||
|
||||
from .conversation_manager import ConversationManager
|
||||
|
||||
@@ -335,15 +335,15 @@ class StandaloneConversationManager(ConversationManager):
|
||||
)
|
||||
await self.close_session(oldest_conversation_id)
|
||||
|
||||
llm_registry, conversation_stats, config = (
|
||||
create_registry_and_conversation_stats(self.config, sid, user_id, settings)
|
||||
llm_registry, convo_stats, config = create_registry_and_convo_stats(
|
||||
self.config, sid, user_id, settings
|
||||
)
|
||||
session = Session(
|
||||
sid=sid,
|
||||
file_store=self.file_store,
|
||||
config=config,
|
||||
llm_registry=llm_registry,
|
||||
conversation_stats=conversation_stats,
|
||||
convo_stats=convo_stats,
|
||||
sio=self.sio,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@@ -63,7 +63,7 @@ from openhands.server.user_auth import (
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
from openhands.server.utils import get_conversation as get_conversation_metadata
|
||||
from openhands.server.utils import get_conversation_store, validate_conversation_id
|
||||
from openhands.server.utils import get_conversation_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
@@ -297,7 +297,7 @@ async def search_conversations(
|
||||
|
||||
@app.get('/conversations/{conversation_id}')
|
||||
async def get_conversation(
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
conversation_id: str,
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
) -> ConversationInfo | None:
|
||||
try:
|
||||
@@ -319,7 +319,7 @@ async def get_conversation(
|
||||
|
||||
@app.delete('/conversations/{conversation_id}')
|
||||
async def delete_conversation(
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
conversation_id: str,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> bool:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
@@ -338,8 +338,8 @@ async def delete_conversation(
|
||||
|
||||
@app.get('/conversations/{conversation_id}/remember-prompt')
|
||||
async def get_prompt(
|
||||
conversation_id: str,
|
||||
event_id: int,
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
user_settings: SettingsStore = Depends(get_user_settings_store),
|
||||
metadata: ConversationMetadata = Depends(get_conversation_metadata),
|
||||
):
|
||||
@@ -440,8 +440,8 @@ async def _get_conversation_info(
|
||||
|
||||
@app.post('/conversations/{conversation_id}/start')
|
||||
async def start_conversation(
|
||||
conversation_id: str,
|
||||
providers_set: ProvidersSetModel,
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
user_id: str = Depends(get_user_id),
|
||||
settings: Settings = Depends(get_user_settings),
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
@@ -501,7 +501,7 @@ async def start_conversation(
|
||||
|
||||
@app.post('/conversations/{conversation_id}/stop')
|
||||
async def stop_conversation(
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
conversation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> ConversationResponse:
|
||||
"""Stop an agent loop for a conversation.
|
||||
@@ -606,8 +606,8 @@ class UpdateConversationRequest(BaseModel):
|
||||
|
||||
@app.patch('/conversations/{conversation_id}')
|
||||
async def update_conversation(
|
||||
conversation_id: str,
|
||||
data: UpdateConversationRequest,
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
) -> bool:
|
||||
@@ -714,8 +714,7 @@ async def update_conversation(
|
||||
|
||||
@app.post('/conversations/{conversation_id}/exp-config')
|
||||
def add_experiment_config_for_conversation(
|
||||
exp_config: ExperimentConfig,
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
conversation_id: str, exp_config: ExperimentConfig
|
||||
) -> bool:
|
||||
exp_config_filepath = get_experiment_config_filename(conversation_id)
|
||||
exists = False
|
||||
|
||||
@@ -53,7 +53,7 @@ async def initialize_conversation(
|
||||
conversation_title = get_default_conversation_title(conversation_id)
|
||||
|
||||
logger.info(f'Saving metadata for conversation {conversation_id}')
|
||||
conversation_metadata = ConversationMetadata(
|
||||
convo_metadata = ConversationMetadata(
|
||||
trigger=conversation_trigger,
|
||||
conversation_id=conversation_id,
|
||||
title=conversation_title,
|
||||
@@ -63,12 +63,12 @@ async def initialize_conversation(
|
||||
git_provider=git_provider,
|
||||
)
|
||||
|
||||
await conversation_store.save_metadata(conversation_metadata)
|
||||
return conversation_metadata
|
||||
await conversation_store.save_metadata(convo_metadata)
|
||||
return convo_metadata
|
||||
|
||||
try:
|
||||
conversation_metadata = await conversation_store.get_metadata(conversation_id)
|
||||
return conversation_metadata
|
||||
convo_metadata = await conversation_store.get_metadata(conversation_id)
|
||||
return convo_metadata
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -83,7 +83,7 @@ async def start_conversation(
|
||||
image_urls: list[str] | None,
|
||||
replay_json: str | None,
|
||||
conversation_id: str,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
convo_metadata: ConversationMetadata,
|
||||
conversation_instructions: str | None,
|
||||
mcp_config: MCPConfig | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
@@ -92,7 +92,7 @@ async def start_conversation(
|
||||
extra={
|
||||
'signal': 'create_conversation',
|
||||
'user_id': user_id,
|
||||
'trigger': conversation_metadata.trigger,
|
||||
'trigger': convo_metadata.trigger,
|
||||
},
|
||||
)
|
||||
logger.info('Loading settings')
|
||||
@@ -119,10 +119,10 @@ async def start_conversation(
|
||||
raise MissingSettingsError('Settings not found')
|
||||
|
||||
session_init_args['git_provider_tokens'] = git_provider_tokens
|
||||
session_init_args['selected_repository'] = conversation_metadata.selected_repository
|
||||
session_init_args['selected_repository'] = convo_metadata.selected_repository
|
||||
session_init_args['custom_secrets'] = custom_secrets
|
||||
session_init_args['selected_branch'] = conversation_metadata.selected_branch
|
||||
session_init_args['git_provider'] = conversation_metadata.git_provider
|
||||
session_init_args['selected_branch'] = convo_metadata.selected_branch
|
||||
session_init_args['git_provider'] = convo_metadata.git_provider
|
||||
session_init_args['conversation_instructions'] = conversation_instructions
|
||||
if mcp_config:
|
||||
session_init_args['mcp_config'] = mcp_config
|
||||
|
||||
@@ -6,9 +6,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.llm.llm_registry import RegistryEvent
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import (
|
||||
get_conversation_stats_filename,
|
||||
)
|
||||
from openhands.storage.locations import get_conversation_stats_filename
|
||||
|
||||
|
||||
class ConversationStats:
|
||||
@@ -39,10 +37,6 @@ class ConversationStats:
|
||||
pickled = pickle.dumps(self.service_to_metrics)
|
||||
serialized_metrics = base64.b64encode(pickled).decode('utf-8')
|
||||
self.file_store.write(self.metrics_path, serialized_metrics)
|
||||
logger.info(
|
||||
'Saved converation stats',
|
||||
extra={'conversation_id': self.conversation_id},
|
||||
)
|
||||
|
||||
def maybe_restore_metrics(self):
|
||||
if not self.file_store or not self.conversation_id:
|
||||
@@ -60,6 +54,9 @@ class ConversationStats:
|
||||
total_metrics = Metrics()
|
||||
for metrics in self.service_to_metrics.values():
|
||||
total_metrics.merge(metrics)
|
||||
|
||||
logger.info(f'metrics by all services: {self.service_to_metrics}')
|
||||
logger.info(f'combined metrics\n\n{total_metrics}')
|
||||
return total_metrics
|
||||
|
||||
def get_metrics_for_service(self, service_id: str) -> Metrics:
|
||||
|
||||
@@ -67,7 +67,7 @@ class AgentSession:
|
||||
sid: str,
|
||||
file_store: FileStore,
|
||||
llm_registry: LLMRegistry,
|
||||
conversation_stats: ConversationStats,
|
||||
convo_stats: ConversationStats,
|
||||
status_callback: Callable | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
@@ -86,7 +86,7 @@ class AgentSession:
|
||||
extra={'session_id': sid, 'user_id': user_id}
|
||||
)
|
||||
self.llm_registry = llm_registry
|
||||
self.conversation_stats = conversation_stats
|
||||
self.convo_stats = convo_stats
|
||||
|
||||
async def start(
|
||||
self,
|
||||
@@ -450,7 +450,7 @@ class AgentSession:
|
||||
user_id=self.user_id,
|
||||
file_store=self.file_store,
|
||||
event_stream=self.event_stream,
|
||||
conversation_stats=self.conversation_stats,
|
||||
convo_stats=self.convo_stats,
|
||||
agent=agent,
|
||||
iteration_delta=int(max_iterations),
|
||||
budget_per_task_delta=max_budget_per_task,
|
||||
|
||||
@@ -55,7 +55,7 @@ class Session:
|
||||
sid: str,
|
||||
config: OpenHandsConfig,
|
||||
llm_registry: LLMRegistry,
|
||||
conversation_stats: ConversationStats,
|
||||
convo_stats: ConversationStats,
|
||||
file_store: FileStore,
|
||||
sio: socketio.AsyncServer | None,
|
||||
user_id: str | None = None,
|
||||
@@ -66,12 +66,12 @@ class Session:
|
||||
self.file_store = file_store
|
||||
self.logger = OpenHandsLoggerAdapter(extra={'session_id': sid})
|
||||
self.llm_registry = llm_registry
|
||||
self.conversation_stats = conversation_stats
|
||||
self.convo_stats = convo_stats
|
||||
self.agent_session = AgentSession(
|
||||
sid,
|
||||
file_store,
|
||||
llm_registry=self.llm_registry,
|
||||
conversation_stats=conversation_stats,
|
||||
convo_stats=convo_stats,
|
||||
status_callback=self.queue_status_message,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user