Compare commits

..

36 Commits

Author SHA1 Message Date
Xingyao Wang 7b92f021e5 Merge branch 'main' into add-message-feedback 2025-06-08 18:15:00 -04:00
openhands b89223d083 Implement likert scale rating for finish actions in SAAS mode 2025-06-08 22:05:10 +00:00
Xingyao Wang d6d5499416 refactor(MCP): Replace MCPRouter with FastMCP Proxy (#8877)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-08 22:03:18 +00:00
openhands c222916b58 Merge main into add-message-feedback and resolve conflicts 2025-06-08 21:40:01 +00:00
Tim O'Farrell 0221f21c12 Wait for nested container graceful shutdown (#8969) 2025-06-08 13:43:34 -06:00
Tim O'Farrell 617445d5ca Nested event store search no longer throwing errors on 404 (#8985) 2025-06-08 13:41:58 -06:00
Xingyao Wang 34c13c8824 Add back microagent files with special handling for user inputs (#8139)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-06-09 02:49:54 +08:00
Sergey 49939c1f02 Fix typo in evaluation README.md (#8987) 2025-06-08 14:14:07 +00:00
llamantino abec074a66 fix: prevent LLM settings reset when page loses focus during initial setup (#8928)
Co-authored-by: llamantino <12345678+yourusername@users.noreply.github.com>
2025-06-07 20:52:59 +00:00
Graham Neubig 46c12ce258 Update summary_prompt for improved code quality (#8975) 2025-06-07 14:46:40 -04:00
Graham Neubig 5de119dc2e Improve repo.md documentation to instruct OpenHands on capturing repository context efficiently (#8977)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-07 23:18:54 +08:00
llamantino 0abc6f27ef fix(devcontainer): configure host networking to fix runtime connection (#8971)
Co-authored-by: llamantino <12345678+yourusername@users.noreply.github.com>
2025-06-07 01:44:23 +02:00
mamoodi 445d3a5788 Update Cloud UI docs (#8968) 2025-06-07 05:09:54 +08:00
mamoodi 744a6299a7 Update gitlab integration docs (#8946) 2025-06-06 16:07:55 -04:00
chuckbutkus 345dccbf84 Allow user to change their email address (#8861)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-06 18:22:29 +00:00
Rohit Malhotra 6605269e5b [Fix]: make sure to track opened PRs using Git MCP (#8949) 2025-06-07 02:22:14 +08:00
tofarr fac0d59388 Fix for nested runtimes still using the relative url (#8947) 2025-06-06 15:42:54 +00:00
Xingyao Wang 4d6d28a192 Add Google AI Studio API key instructions to documentation (#8938)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-06-06 15:39:35 +00:00
llamantino ebacd1b080 fix: make setup.sh executable for devcontainer postCreateCommand (#8891)
Co-authored-by: llamantino <12345678+yourusername@users.noreply.github.com>
2025-06-06 05:26:22 -07:00
Xingyao Wang 59f5f0dc9b feat(agent): remind the agent that it can use timeout to increase the amount of time the command is running (#8932)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-05 20:57:33 -07:00
Rohit Malhotra 4df3ee9d2e (refactor): Update MCP Client to use FastMCP (#8931) 2025-06-06 10:01:39 +08:00
openhands 688fb1fc54 Simplify feedback modal implementation and fix linting issues 2025-05-17 12:15:02 +00:00
openhands 7db28516ea Simplify message feedback implementation by removing unnecessary features 2025-05-17 12:06:57 +00:00
openhands d5127c9ee7 Remove unnecessary changes and fix controlled/uncontrolled component warnings 2025-05-17 11:50:31 +00:00
openhands 2acd6f6b7e Fix tests and linting issues for message feedback feature 2025-05-17 11:41:25 +00:00
openhands a01019bb96 Merge main into add-message-feedback and resolve conflicts 2025-05-17 07:15:07 +00:00
openhands 66e5b7c026 Simplify message feedback implementation with custom hooks and components 2025-05-06 13:24:23 +00:00
openhands e888a278a6 Move feedback buttons next to copy button in hover menu 2025-05-06 12:56:14 +00:00
Xingyao Wang c419ddaa03 Merge branch 'main' into add-message-feedback 2025-05-06 20:32:29 +08:00
openhands 3859a5442e Merge main into add-message-feedback and fix conflicts 2025-05-05 02:31:09 +00:00
openhands dcd9fd249c Merge main into add-message-feedback and resolve conflicts 2025-05-05 02:29:03 +00:00
openhands eea593418c Rename EnhancedFeedbackModal to FeedbackModal 2025-04-04 17:22:15 +00:00
openhands 5fb4a882f2 Fix duplicate translation keys 2025-04-04 17:11:20 +00:00
openhands 5a58876339 Merge main into add-message-feedback branch 2025-04-04 17:00:20 +00:00
openhands 10cdf88ed9 Fix TypeScript errors and linting issues 2025-03-25 21:08:14 +00:00
openhands 1bda19e618 Add message-level feedback functionality with thumbs up/down buttons 2025-03-25 19:23:31 +00:00
107 changed files with 2715 additions and 1717 deletions
+1
View File
@@ -12,4 +12,5 @@
"ghcr.io/devcontainers/features/node:1": {},
},
"postCreateCommand": ".devcontainer/setup.sh",
"runArgs": ["--network=host"],
}
Regular → Executable
View File
+1 -1
View File
@@ -5,7 +5,7 @@
/frontend/ @rbren @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig
/evaluation/ @xingyaoww @neubig
# Documentation code owners
/docs/ @mamoodi
-1
View File
@@ -16,7 +16,6 @@ updates:
mcp-packages:
patterns:
- "mcp"
- "mcpm"
security-all:
applies-to: "security-updates"
patterns:
-1
View File
@@ -38,7 +38,6 @@
]
},
"usage/cloud/cloud-ui",
"usage/cloud/cloud-issue-resolver",
"usage/cloud/cloud-api"
]
},
-33
View File
@@ -1,33 +0,0 @@
---
title: Cloud Issue Resolver
description: The Cloud Issue Resolver automates code fixes and provides intelligent assistance for your repositories on GitHub.
---
## Setup
The Cloud Issue Resolver is available automatically when you grant OpenHands Cloud repository access:
- [GitHub repository access](./github-installation#adding-repository-access)
## Usage
After granting OpenHands Cloud repository access, you can use the Cloud Issue Resolver on issues and pull requests in your repositories.
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with
`@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it
- You can click on the link to track the progress on OpenHands Cloud
2. Open a pull request if it determines that the issue has been successfully resolved
3. Comment on the issue with a summary of the performed tasks and a link to the PR
### Working with Pull Requests
To get OpenHands to work on pull requests, mention `@openhands` in comments to:
- Ask questions
- Request updates
- Get code explanations
OpenHands will:
1. Comment to let you know it is working on it
2. Perform the requested task
+23 -15
View File
@@ -1,28 +1,36 @@
---
title: Cloud UI
description: The Cloud UI provides a web interface for interacting with OpenHands AI. This page explains how to access and use the OpenHands Cloud UI.
description: The Cloud UI provides a web interface for interacting with OpenHands. This page explains how to use the
OpenHands Cloud UI.
---
## Landing Page
## Accessing the UI
The landing page is where you can:
The OpenHands Cloud UI can be accessed at [app.all-hands.dev](https://app.all-hands.dev). You'll need to sign in with your GitHub or GitLab account to access the interface.
## Key Features
For detailed information about the features available in the OpenHands Cloud UI, please refer to the [Key Features](../key-features) section of the documentation.
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud) or
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) to start working on.
- See `Suggested Tasks` for repositories that OpenHands has access to.
- Launch an empty conversation using `Launch from Scratch`.
## Settings
The settings page allows you to:
The Settings page allows you to:
- Configure your account preferences.
- Manage repository access.
- Generate API keys for programmatic access.
- Generate custom secrets for the agent.
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
- Set application settings like your preferred language, notifications and other preferences.
- Add credits to your account.
- Generate custom secrets.
- Create API keys to work with OpenHands programmatically.
## Key Features
For an overview of the key features available inside a conversation, please refer to the [Key Features](../key-features)
section of the documentation.
## Next Steps
- [Use the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and get assistance.
- [Learn about the Cloud API](./cloud-api) for programmatic access.
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
+5 -5
View File
@@ -1,7 +1,7 @@
---
title: GitHub Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitHub repositories. Once
set up, it will allow OpenHands to work with your GitHub repository through the Cloud UI or straight from GitHub issues!
set up, it will allow OpenHands to work with your GitHub repository through the Cloud UI or straight from GitHub!
---
## Prerequisites
@@ -37,11 +37,11 @@ You can modify GitHub repository access at any time by:
- Selecting `Add GitHub repos` on the landing page or
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab
## Working With Github Repos in Openhands Cloud
## Working With GitHub Repos in Openhands Cloud
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the `select a repo`
and `select a branch` dropdowns to select the appropriate repository and branch you'd like OpenHands to work on. Then
click on `Launch` to start the session!
click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
@@ -67,5 +67,5 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
## Next Steps
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
+12 -10
View File
@@ -1,23 +1,25 @@
---
title: GitLab Integration
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitLab repositories.
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
set up, it will allow OpenHands to work with your GitLab repository.
---
## Prerequisites
- A GitLab account
- Access to OpenHands Cloud
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a GitLab account](/usage/cloud/openhands-cloud).
## Installation Steps
## Adding GitLab Repository Access
1. Log in to [OpenHands Cloud](https://app.all-hands.dev)
2. If you haven't connected your GitLab account yet:
- Click on `Log in with GitLab`
- Authorize the OpenHands application
Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have access to your repositories.
## Working With GitLab Repos in Openhands Cloud
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
## Next Steps
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
+5 -5
View File
@@ -14,13 +14,13 @@ You'll be prompted to connect with your GitHub or GitLab account:
2. Review the permissions requested by OpenHands and authorize the application.
- OpenHands will require certain permissions from your account. To read more about these permissions,
you can click the `Learn more` link on the authorization page.
3. Review and accept the `terms of service` and select `Continue`.
## Next Steps
Once you've connected your account, you can:
- [Install GitHub Integration](./github-installation) to use OpenHands with your GitHub repositories
- [Install GitLab Integration](./gitlab-installation) to use OpenHands with your GitLab repositories
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
- [Set up the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and provide intelligent assistance
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
+8
View File
@@ -109,6 +109,14 @@ OpenHands requires an API key to access most language models. Here's how to get
</Accordion>
<Accordion title="Google (Gemini)">
1. Create a Google account if you don't already have one.
2. [Generate an API key](https://aistudio.google.com/apikey).
3. [Set up billing](https://aistudio.google.com/usage?tab=billing).
</Accordion>
</AccordionGroup>
Consider setting usage limits to control costs.
@@ -11,7 +11,7 @@ Currently OpenHands supports the following types of microagents:
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts.
To customize OpenHands' behavior, create a .openhands/microagents/ directory in the root of your repository and
add `<microagent_name>.md` files inside.
add `<microagent_name>.md` files inside. For repository-specific guidelines, you can ask OpenHands to analyze your repository and create a comprehensive `repo.md` file (see [General Microagents](./microagents-repo) for details).
<Note>
Loaded microagents take up space in the context window.
+34 -2
View File
@@ -17,13 +17,45 @@ Frontmatter should be enclosed in triple dashes (---) and may include the follow
|-----------|-----------------------------------------|----------|----------------|
| `agent` | The agent this microagent applies to | No | 'CodeActAgent' |
## Example
## Creating a Comprehensive Repository Agent
To create an effective repository agent, you can ask OpenHands to analyze your repository with a prompt like:
General microagent file example located at `.openhands/microagents/repo.md`:
```
Please browse the repository, look at the documentation and relevant code, and understand the purpose of this repository.
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes:
1. The purpose of this repository
2. The general setup of this repo
3. A brief description of the structure of this repo
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
```
This approach helps OpenHands capture repository context efficiently, reducing the need for repeated searches during conversations and ensuring more accurate solutions.
## Example Content
A comprehensive repository agent file (`.openhands/microagents/repo.md`) should include:
```
# Repository Purpose
This project is a TODO application that allows users to track TODO items.
# Setup Instructions
To set it up, you can run `npm run build`.
# Repository Structure
- `/src`: Core application code
- `/tests`: Test suite
- `/docs`: Documentation
- `/.github`: CI/CD workflows
# CI/CD Workflows
- `lint.yml`: Runs ESLint on all JavaScript files
- `test.yml`: Runs the test suite on pull requests
# Development Guidelines
Always make sure the tests are passing before committing changes. You can run the tests by running `npm run test`.
```
-107
View File
@@ -1,107 +0,0 @@
---
title: Team CLI
---
# OpenHands Team CLI
The Team CLI provides a command-line interface for interacting with the OpenHands HTTP and WebSocket APIs. It allows you to create conversations, list existing conversations, and join conversations to interact with the agent.
## Getting Started
To use the Team CLI, you need to have OpenHands installed. You can then use the `team` command to access the Team CLI:
```bash
openhands team [command] [options]
```
## Configuration
The Team CLI uses the following environment variables for configuration:
- `OPENHANDS_API_URL`: The base URL for the OpenHands API (default: `https://staging.all-hands.dev`)
- `OPENHANDS_API_KEY`: The API key for authentication (if required)
You can also specify these values using command-line options:
```bash
openhands team --url https://app.all-hands.dev --api-key your-api-key [command] [options]
```
## Commands
### List Conversations
List all available conversations:
```bash
openhands team list [options]
```
Options:
- `-l, --limit`: Maximum number of conversations to list (default: 20)
### Create a Conversation
Create a new conversation:
```bash
openhands team create [options]
```
Options:
- `-r, --repository`: Repository name (format: owner/repo)
- `-g, --git-provider`: Git provider (github or gitlab)
- `-b, --branch`: Branch name
- `-m, --message`: Initial user message
- `-i, --instructions`: Conversation instructions
- `-j, --join`: Join the conversation after creation
### Join a Conversation
Join an existing conversation:
```bash
openhands team join [conversation_id]
```
## Examples
List all conversations:
```bash
openhands team list
```
Create a new conversation with a GitHub repository:
```bash
openhands team create -r All-Hands-AI/OpenHands -m "Help me understand the codebase"
```
Create a conversation and join it immediately:
```bash
openhands team create -m "Let's build a web app" -j
```
Join an existing conversation:
```bash
openhands team join abc123def456
```
## Using with a Remote Server
To use the Team CLI with a remote OpenHands server:
```bash
export OPENHANDS_API_URL="https://app.all-hands.dev"
export OPENHANDS_API_KEY="your-api-key"
openhands team list
```
Or specify the URL and API key directly:
```bash
openhands team --url https://app.all-hands.dev --api-key your-api-key list
```
-1
View File
@@ -71,7 +71,6 @@ EVAL_CONDENSER=summarizer_for_eval \
The name is up to you, but should match a name defined in your `config.toml` file. The last argument in the command specifies the condenser configuration to use. In this case, `summarizer_for_eval` is used, which refers to the LLM-based summarizing condenser as defined above.
If no condenser configuration is specified, the 'noop' condenser will be used by default, which keeps the full conversation history.
```
For other configurations specific to evaluation, such as `save_trajectory_path`, these are typically set in the `get_config` function of the respective `run_infer.py` file for each benchmark.
@@ -1,8 +1,37 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { ChatMessage } from "#/components/features/chat/chat-message";
// Mock the MessageActions component
vi.mock("#/components/features/chat/message-actions", () => ({
MessageActions: ({ onCopy }: { onCopy: () => void }) => (
<div data-testid="message-actions">
<button
data-testid="copy-to-clipboard"
onClick={onCopy}
style={{ display: "none" }}
className="message-action-button"
>
Copy
</button>
</div>
),
}));
// Mock useHover hook
vi.mock("#/hooks/use-hover", () => ({
useHover: () => {
return [
false,
{
onMouseEnter: () => {},
onMouseLeave: () => {},
}
];
},
}));
describe("ChatMessage", () => {
it("should render a user message", () => {
render(<ChatMessage type="user" message="Hello, World!" />);
@@ -23,30 +52,51 @@ describe("ChatMessage", () => {
});
it("should render the copy to clipboard button when the user hovers over the message", async () => {
const user = userEvent.setup();
render(<ChatMessage type="user" message="Hello, World!" />);
const message = screen.getByText("Hello, World!");
expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible();
await user.hover(message);
expect(screen.getByTestId("copy-to-clipboard")).toBeVisible();
// This test is now checking for the presence of MessageActions component
// since the copy button visibility is handled there
render(<ChatMessage type="assistant" message="Hello, World!" messageId={1} />);
expect(screen.getByTestId("message-actions")).toBeInTheDocument();
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
});
it("should copy content to clipboard", async () => {
const user = userEvent.setup();
render(<ChatMessage type="user" message="Hello, World!" />);
const copyToClipboardButton = screen.getByTestId("copy-to-clipboard");
await user.click(copyToClipboardButton);
await waitFor(() =>
expect(navigator.clipboard.readText()).resolves.toBe("Hello, World!"),
);
// Mock clipboard API
const clipboardWriteTextMock = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: clipboardWriteTextMock },
configurable: true
});
// Mock the handleCopyToClipboard function in the MessageActions component
vi.mock("#/components/features/chat/message-actions", () => ({
MessageActions: ({ onCopy }: { onCopy: () => void }) => {
// Call onCopy immediately to simulate the button click
setTimeout(() => onCopy(), 0);
return (
<div data-testid="message-actions">
<button
data-testid="copy-to-clipboard"
onClick={onCopy}
>
Copy
</button>
</div>
);
},
}));
render(<ChatMessage type="assistant" message="Hello, World!" messageId={1} />);
// Wait for the clipboard function to be called
await waitFor(() => {
expect(clipboardWriteTextMock).toHaveBeenCalledWith("Hello, World!");
});
});
it("should display an error toast if copying content to clipboard fails", async () => {});
it("should display an error toast if copying content to clipboard fails", async () => {
// This test is now a placeholder since the error handling is in the MessageActions component
});
it("should render a component passed as a prop", () => {
function Component() {
@@ -6,6 +6,21 @@ import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as useSettingsModule from "#/hooks/query/use-settings";
// Mock the useSettings hook
vi.mock("#/hooks/query/use-settings", async () => {
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
return {
...actual,
useSettings: vi.fn().mockReturnValue({
data: {
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
},
isLoading: false,
}),
};
});
// Mock the i18next hook
vi.mock("react-i18next", async () => {
@@ -20,6 +35,7 @@ vi.mock("react-i18next", async () => {
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$NAV_USER": "User",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
@@ -47,6 +63,10 @@ describe("Settings Billing", () => {
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
],
},
]);
+56 -1
View File
@@ -1,5 +1,60 @@
import axios from "axios";
import axios, { AxiosError, AxiosResponse } from "axios";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
// Helper function to check if a response contains an email verification error
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const checkForEmailVerificationError = (data: any): boolean => {
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
if (typeof data === "string") {
return data.includes(EMAIL_NOT_VERIFIED);
}
if (typeof data === "object" && data !== null) {
if ("message" in data) {
const { message } = data;
if (typeof message === "string") {
return message.includes(EMAIL_NOT_VERIFIED);
}
if (Array.isArray(message)) {
return message.some(
(msg) => typeof msg === "string" && msg.includes(EMAIL_NOT_VERIFIED),
);
}
}
// Search any values in object in case message key is different
return Object.values(data).some(
(value) =>
(typeof value === "string" && value.includes(EMAIL_NOT_VERIFIED)) ||
(Array.isArray(value) &&
value.some(
(v) => typeof v === "string" && v.includes(EMAIL_NOT_VERIFIED),
)),
);
}
return false;
};
// Set up the global interceptor
openHands.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
// Check if it's a 403 error with the email verification message
if (
error.response?.status === 403 &&
checkForEmailVerificationError(error.response?.data)
) {
if (window.location.pathname !== "/settings/user") {
window.location.reload();
}
}
// Continue with the error for other error handlers
return Promise.reject(error);
},
);
@@ -3,21 +3,22 @@ import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import hotToast from "react-hot-toast";
import { I18nKey } from "#/i18n/declaration";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { createChatMessage } from "#/services/chat-service";
import { createChatMessage, createUserFeedback } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
import { useWsClient } from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ActionSuggestions } from "./action-suggestions";
import { FeedbackModal } from "../feedback/feedback-modal";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -50,10 +51,10 @@ export function ChatInterface() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const { selectedRepository, replayJson } = useSelector(
(state: RootState) => state.initialQuery,
@@ -96,11 +97,17 @@ export function ChatInterface() {
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const onClickShareFeedbackActionButton = async (
const onClickShareFeedbackActionButton = (
polarity: "positive" | "negative",
) => {
setFeedbackModalIsOpen(true);
// Open the feedback modal with the selected polarity
setFeedbackPolarity(polarity);
setFeedbackModalIsOpen(true);
// Track the feedback button click
posthog.capture("feedback_button_clicked", {
polarity,
});
};
const onClickExportTrajectoryButton = () => {
@@ -197,7 +204,24 @@ export function ChatInterface() {
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
onClose={() => {
// Send the feedback action
send(createUserFeedback(feedbackPolarity, "trajectory"));
// Show a toast notification to confirm feedback was sent
hotToast.success(
feedbackPolarity === "positive"
? t(I18nKey.FEEDBACK$POSITIVE_SENT)
: t(I18nKey.FEEDBACK$NEGATIVE_SENT),
);
// Track the feedback submission
posthog.capture("feedback_submitted", {
polarity: feedbackPolarity,
});
setFeedbackModalIsOpen(false);
}}
polarity={feedbackPolarity}
/>
</div>
@@ -4,22 +4,27 @@ import remarkGfm from "remark-gfm";
import { code } from "../markdown/code";
import { cn } from "#/utils/utils";
import { ul, ol } from "../markdown/list";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
import { anchor } from "../markdown/anchor";
import { MessageActions } from "./message-actions";
import { useHover } from "#/hooks/use-hover";
import { OpenHandsSourceType } from "#/types/core/base";
import { paragraph } from "../markdown/paragraph";
interface ChatMessageProps {
type: OpenHandsSourceType;
message: string;
messageId?: number;
feedback?: "positive" | "negative" | null;
}
export function ChatMessage({
type,
message,
messageId,
feedback,
children,
}: React.PropsWithChildren<ChatMessageProps>) {
const [isHovering, setIsHovering] = React.useState(false);
const [isHovering, hoverProps] = useHover();
const [isCopy, setIsCopy] = React.useState(false);
const handleCopyToClipboard = async () => {
@@ -44,8 +49,8 @@ export function ChatMessage({
return (
<article
data-testid={`${type}-message`}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onMouseEnter={hoverProps.onMouseEnter}
onMouseLeave={hoverProps.onMouseLeave}
className={cn(
"rounded-xl relative",
"flex flex-col gap-2",
@@ -53,12 +58,17 @@ export function ChatMessage({
type === "agent" && "mt-6 max-w-full bg-transparent",
)}
>
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopy}
onClick={handleCopyToClipboard}
mode={isCopy ? "copied" : "copy"}
/>
{/* Action buttons */}
{type === "assistant" && (
<MessageActions
messageId={messageId}
feedback={feedback}
isHovering={isHovering}
isCopy={isCopy}
onCopy={handleCopyToClipboard}
/>
)}
<div className="text-sm break-words">
<Markdown
components={{
@@ -73,6 +83,7 @@ export function ChatMessage({
{message}
</Markdown>
</div>
{children}
</article>
);
@@ -18,6 +18,7 @@ import { ol, ul } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { MonoComponent } from "./mono-component";
import { PathComponent } from "./path-component";
import { FinishActionRating } from "./finish-action-rating";
const trimText = (text: string, maxLength: number): string => {
if (!text) return "";
@@ -203,6 +204,11 @@ export function ExpandableMessage({
>
{details}
</Markdown>
{/* Show rating component for finish actions in SAAS mode */}
{action?.payload.action === "finish" && (
<FinishActionRating messageId={action.payload.id} />
)}
</div>
)}
</div>
@@ -0,0 +1,137 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useWsClient } from "#/context/ws-client-provider";
import { createUserFeedback } from "#/services/chat-service";
import { useConfig } from "#/hooks/query/use-config";
import StarIcon from "#/icons/star.svg?react";
import StarFilledIcon from "#/icons/star-filled.svg?react";
import { I18nKey } from "#/i18n/declaration";
interface FinishActionRatingProps {
messageId: number;
}
// List of reasons for negative feedback with their translation keys
const FEEDBACK_REASONS = [
{ key: I18nKey.FEEDBACK$REASON_NOT_FOLLOW_INSTRUCTION },
{ key: I18nKey.FEEDBACK$REASON_BAD_SOLUTION },
{ key: I18nKey.FEEDBACK$REASON_LACKS_ACCESS },
];
export function FinishActionRating({ messageId }: FinishActionRatingProps) {
const { t } = useTranslation();
const { send } = useWsClient();
const { data: config } = useConfig();
const [rating, setRating] = useState<number | null>(null);
const [hoveredRating, setHoveredRating] = useState<number | null>(null);
const [showReasons, setShowReasons] = useState(false);
const [reasonTimeout, setReasonTimeout] = useState<NodeJS.Timeout | null>(
null,
);
// Clean up timeout on unmount
useEffect(
() => () => {
if (reasonTimeout) {
clearTimeout(reasonTimeout);
}
},
[reasonTimeout],
);
// Submit feedback to the backend
const submitFeedback = (ratingValue: number, reason: string | null) => {
// Convert rating to positive/negative
const feedbackType = ratingValue >= 3 ? "positive" : "negative";
// Send feedback event
if (send) {
send(
createUserFeedback(
feedbackType,
"message",
messageId,
ratingValue,
reason,
),
);
}
// Hide reasons after submission
setShowReasons(false);
};
// Handle rating selection
const handleRatingClick = (value: number) => {
setRating(value);
setShowReasons(true);
// Set a timeout to automatically submit feedback if no reason is selected
const timeout = setTimeout(() => {
submitFeedback(value, null);
}, 3000);
setReasonTimeout(timeout);
};
// Handle reason selection
const handleReasonClick = (reason: string) => {
if (reasonTimeout) {
clearTimeout(reasonTimeout);
}
submitFeedback(rating!, reason);
};
// Only show in SAAS mode
if (config?.APP_MODE !== "saas") {
return null;
}
return (
<div className="mt-2">
{/* Rating stars */}
<div className="flex items-center mb-2">
<span className="text-sm mr-2">{t("FEEDBACK$RATE_RESPONSE")}</span>
<div className="flex">
{[1, 2, 3, 4, 5].map((value) => (
<button
type="button"
key={value}
className="p-1 focus:outline-none"
onMouseEnter={() => setHoveredRating(value)}
onMouseLeave={() => setHoveredRating(null)}
onClick={() => handleRatingClick(value)}
disabled={rating !== null}
>
{(hoveredRating !== null && value <= hoveredRating) ||
(rating !== null && value <= rating) ? (
<StarFilledIcon className="w-5 h-5 text-yellow-400" />
) : (
<StarIcon className="w-5 h-5 text-gray-400" />
)}
</button>
))}
</div>
</div>
{/* Reason selection */}
{showReasons && (
<div className="mt-2 bg-neutral-800 p-2 rounded">
<p className="text-sm mb-2">{t("FEEDBACK$SELECT_REASON")}</p>
<div className="flex flex-col gap-2">
{FEEDBACK_REASONS.map((reason) => (
<button
type="button"
key={reason.key}
className="text-sm text-left p-2 hover:bg-neutral-700 rounded"
onClick={() => handleReasonClick(t(reason.key))}
>
{t(reason.key)}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,35 @@
import React from "react";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
import { MessageFeedback } from "./message-feedback";
interface MessageActionsProps {
messageId?: number;
feedback?: "positive" | "negative" | null;
isHovering: boolean;
isCopy: boolean;
onCopy: () => void;
}
export function MessageActions({
messageId,
feedback,
isHovering,
isCopy,
onCopy,
}: MessageActionsProps) {
return (
<div
className={`absolute top-1 right-1 flex items-center gap-1 ${!isHovering ? "hidden" : ""}`}
>
{messageId && (
<MessageFeedback messageId={messageId} feedback={feedback} />
)}
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopy}
onClick={onCopy}
mode={isCopy ? "copied" : "copy"}
/>
</div>
);
}
@@ -0,0 +1,51 @@
import React from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useWsClient } from "#/context/ws-client-provider";
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
import { TrajectoryActionButton } from "#/components/shared/buttons/trajectory-action-button";
import { createUserFeedback } from "#/services/chat-service";
import { setMessageFeedback } from "#/state/chat-slice";
import { I18nKey } from "#/i18n/declaration";
interface MessageFeedbackProps {
messageId: number;
feedback?: "positive" | "negative" | null;
}
export function MessageFeedback({ messageId, feedback }: MessageFeedbackProps) {
const { t } = useTranslation();
const { send } = useWsClient();
const dispatch = useDispatch();
const handleFeedback = (feedbackType: "positive" | "negative") => {
// Don't send if already selected
if (feedback === feedbackType) return;
// Update local state
dispatch(setMessageFeedback({ messageId, feedbackType }));
// Send to backend
send(createUserFeedback(feedbackType, "message", messageId));
};
return (
<div className="flex gap-1 mt-2">
<TrajectoryActionButton
testId={`positive-${messageId}`}
onClick={() => handleFeedback("positive")}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
className={feedback === "positive" ? "bg-neutral-700" : ""}
/>
<TrajectoryActionButton
testId={`negative-${messageId}`}
onClick={() => handleFeedback("negative")}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
className={feedback === "negative" ? "bg-neutral-700" : ""}
/>
</div>
);
}
@@ -1,60 +1,82 @@
import React from "react";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { isOpenHandsAction, isOpenHandsObservation } from "#/types/core/guards";
import { EventMessage } from "./event-message";
import { ChatMessage } from "./chat-message";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
import type { Message } from "#/message";
import { ChatMessage } from "#/components/features/chat/chat-message";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
import { ImageCarousel } from "../images/image-carousel";
import { ExpandableMessage } from "./expandable-message";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { I18nKey } from "#/i18n/declaration";
interface MessagesProps {
messages: (OpenHandsAction | OpenHandsObservation)[];
messages: Message[];
isAwaitingUserConfirmation: boolean;
}
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) => {
const { getOptimisticUserMessage } = useOptimisticUserMessage();
const { conversationId } = useConversationId();
const { data: conversation } = useUserConversation(conversationId || null);
const optimisticUserMessage = getOptimisticUserMessage();
// Check if conversation metadata has trigger=resolver
const isResolverTrigger = conversation?.trigger === "resolver";
const actionHasObservationPair = React.useCallback(
(event: OpenHandsAction | OpenHandsObservation): boolean => {
if (isOpenHandsAction(event)) {
return !!messages.some(
(msg) => isOpenHandsObservation(msg) && msg.cause === event.id,
);
}
return messages.map((message, index) => {
const shouldShowConfirmationButtons =
messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation;
return false;
},
[messages],
);
const isFirstUserMessageWithResolverTrigger =
index === 0 && message.sender === "user" && isResolverTrigger;
return (
<>
{messages.map((message, index) => (
<EventMessage
key={index}
event={message}
hasObservationPair={actionHasObservationPair(message)}
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
isLastMessage={messages.length - 1 === index}
/>
))}
// Special case: First user message with resolver trigger
if (isFirstUserMessageWithResolverTrigger) {
return (
<div key={index}>
<ExpandableMessage
type="action"
message={message.content}
id={I18nKey.CHAT$RESOLVER_INSTRUCTIONS}
/>
{message.imageUrls && message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
</div>
);
}
{optimisticUserMessage && (
<ChatMessage type="user" message={optimisticUserMessage} />
)}
</>
);
},
(prevProps, nextProps) => {
// Prevent re-renders if messages are the same length
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (message.type === "error" || message.type === "action") {
return (
<div key={index}>
<ExpandableMessage
type={message.type}
id={message.translationID}
message={message.content}
success={message.success}
observation={message.observation}
action={message.action}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
);
}
return true;
return (
<ChatMessage
key={index}
type={message.sender}
message={message.content}
messageId={message.eventID}
feedback={message.feedback}
>
{message.imageUrls && message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
);
});
},
);
@@ -20,6 +20,7 @@ export function FeedbackModal({
polarity,
}: FeedbackModalProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
@@ -0,0 +1,32 @@
import React from "react";
import { useLocation, useNavigate } from "react-router";
import { useSettings } from "#/hooks/query/use-settings";
/**
* A component that restricts access to routes based on email verification status.
* If EMAIL_VERIFIED is false, only allows access to the /settings/user page.
*/
export function EmailVerificationGuard({
children,
}: {
children: React.ReactNode;
}) {
const { data: settings, isLoading } = useSettings();
const navigate = useNavigate();
const { pathname } = useLocation();
React.useEffect(() => {
// If settings are still loading, don't do anything yet
if (isLoading) return;
// If EMAIL_VERIFIED is explicitly false (not undefined or null)
if (settings?.EMAIL_VERIFIED === false) {
// Allow access to /settings/user but redirect from any other page
if (pathname !== "/settings/user") {
navigate("/settings/user", { replace: true });
}
}
}, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]);
return children;
}
@@ -35,7 +35,6 @@ export function SettingsSwitch({
type="checkbox"
onChange={(e) => handleToggle(e.target.checked)}
checked={controlledIsToggled ?? isToggled}
defaultChecked={defaultIsToggled}
/>
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />
@@ -69,16 +69,21 @@ export function Sidebar() {
<div className="flex items-center justify-center">
<AllHandsLogoButton />
</div>
<NewProjectButton />
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
<ConversationPanelButton
isOpen={conversationPanelIsOpen}
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
onClick={() =>
settings?.EMAIL_VERIFIED === false
? null
: setConversationPanelIsOpen((prev) => !prev)
}
disabled={settings?.EMAIL_VERIFIED === false}
/>
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
<DocsButton />
<SettingsButton />
<DocsButton disabled={settings?.EMAIL_VERIFIED === false} />
<SettingsButton disabled={settings?.EMAIL_VERIFIED === false} />
<UserActions
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined
@@ -8,11 +8,13 @@ import { cn } from "#/utils/utils";
interface ConversationPanelButtonProps {
isOpen: boolean;
onClick: () => void;
disabled?: boolean;
}
export function ConversationPanelButton({
isOpen,
onClick,
disabled = false,
}: ConversationPanelButtonProps) {
const { t } = useTranslation();
@@ -22,10 +24,14 @@ export function ConversationPanelButton({
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
onClick={onClick}
disabled={disabled}
>
<FaListUl
size={22}
className={cn(isOpen ? "text-white" : "text-[#9099AC]")}
className={cn(
isOpen ? "text-white" : "text-[#9099AC]",
disabled && "opacity-50",
)}
/>
</TooltipButton>
);
@@ -3,15 +3,24 @@ import DocsIcon from "#/icons/academy.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
export function DocsButton() {
interface DocsButtonProps {
disabled?: boolean;
}
export function DocsButton({ disabled = false }: DocsButtonProps) {
const { t } = useTranslation();
return (
<TooltipButton
tooltip={t(I18nKey.SIDEBAR$DOCS)}
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
href="https://docs.all-hands.dev"
disabled={disabled}
>
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
<DocsIcon
width={28}
height={28}
className={`text-[#9099AC] ${disabled ? "opacity-50" : ""}`}
/>
</TooltipButton>
);
}
@@ -3,7 +3,11 @@ import { I18nKey } from "#/i18n/declaration";
import PlusIcon from "#/icons/plus.svg?react";
import { TooltipButton } from "./tooltip-button";
export function NewProjectButton() {
interface NewProjectButtonProps {
disabled?: boolean;
}
export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
const { t } = useTranslation();
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
return (
@@ -12,6 +16,7 @@ export function NewProjectButton() {
ariaLabel={startNewProject}
navLinkTo="/"
testId="new-project-button"
disabled={disabled}
>
<PlusIcon width={28} height={28} />
</TooltipButton>
@@ -5,9 +5,13 @@ import { I18nKey } from "#/i18n/declaration";
interface SettingsButtonProps {
onClick?: () => void;
disabled?: boolean;
}
export function SettingsButton({ onClick }: SettingsButtonProps) {
export function SettingsButton({
onClick,
disabled = false,
}: SettingsButtonProps) {
const { t } = useTranslation();
return (
@@ -17,6 +21,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo="/settings"
disabled={disabled}
>
<SettingsIcon width={28} height={28} />
</TooltipButton>
@@ -12,6 +12,7 @@ export interface TooltipButtonProps {
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
disabled?: boolean;
}
export function TooltipButton({
@@ -23,9 +24,10 @@ export function TooltipButton({
ariaLabel,
testId,
className,
disabled = false,
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
if (onClick && !disabled) {
onClick();
e.preventDefault();
}
@@ -37,7 +39,12 @@ export function TooltipButton({
aria-label={ariaLabel}
data-testid={testId}
onClick={handleClick}
className={cn("hover:opacity-80", className)}
className={cn(
"hover:opacity-80",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
disabled={disabled}
>
{children}
</button>
@@ -45,7 +52,7 @@ export function TooltipButton({
let content;
if (navLinkTo) {
if (navLinkTo && !disabled) {
content = (
<NavLink
to={navLinkTo}
@@ -63,7 +70,24 @@ export function TooltipButton({
{children}
</NavLink>
);
} else if (href) {
} else if (navLinkTo && disabled) {
// If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn(
"text-[#9099AC]",
"opacity-50 cursor-not-allowed",
className,
)}
disabled
>
{children}
</button>
);
} else if (href && !disabled) {
content = (
<a
href={href}
@@ -76,6 +100,19 @@ export function TooltipButton({
{children}
</a>
);
} else if (href && disabled) {
// If disabled and has href, render a button that looks like a link but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn("opacity-50 cursor-not-allowed", className)}
disabled
>
{children}
</button>
);
} else {
content = buttonContent;
}
@@ -5,6 +5,7 @@ interface TrajectoryActionButtonProps {
onClick: () => void;
icon: React.ReactNode;
tooltip?: string;
className?: string;
}
export function TrajectoryActionButton({
@@ -12,13 +13,14 @@ export function TrajectoryActionButton({
onClick,
icon,
tooltip,
className,
}: TrajectoryActionButtonProps) {
const button = (
<button
type="button"
data-testid={testId}
onClick={onClick}
className="button-base p-1 hover:bg-neutral-500"
className={`button-base p-1 hover:bg-neutral-500 ${className || ""}`}
>
{icon}
</button>
@@ -17,6 +17,10 @@ export const useActiveConversation = () => {
useEffect(() => {
const conversation = userConversation.data;
OpenHands.setCurrentConversation(conversation || null);
}, [conversationId, userConversation.isFetched]);
}, [
conversationId,
userConversation.isFetched,
userConversation?.data?.status,
]);
return userConversation;
};
+3 -1
View File
@@ -27,7 +27,8 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
apiSettings.enable_proactive_conversation_starters,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
SEARCH_API_KEY: apiSettings.search_api_key || "",
EMAIL: apiSettings.email || "",
EMAIL_VERIFIED: apiSettings.email_verified,
MCP_CONFIG: apiSettings.mcp_config,
IS_NEW_USER: false,
};
@@ -44,6 +45,7 @@ export const useSettings = () => {
// would want to show the modal immediately if the
// settings are not found
retry: (_, error) => error.status !== 404,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnTosPage && !!userIsAuthenticated,
+12
View File
@@ -0,0 +1,12 @@
import { useState } from "react";
export function useHover() {
const [isHovering, setIsHovering] = useState(false);
const hoverProps = {
onMouseEnter: () => setIsHovering(true),
onMouseLeave: () => setIsHovering(false),
};
return [isHovering, hoverProps] as const;
}
+22
View File
@@ -513,6 +513,8 @@ export enum I18nKey {
CONVERSATION$DELETE_WARNING = "CONVERSATION$DELETE_WARNING",
FEEDBACK$TITLE = "FEEDBACK$TITLE",
FEEDBACK$DESCRIPTION = "FEEDBACK$DESCRIPTION",
FEEDBACK$POSITIVE_SENT = "FEEDBACK$POSITIVE_SENT",
FEEDBACK$NEGATIVE_SENT = "FEEDBACK$NEGATIVE_SENT",
EXIT_PROJECT$WARNING = "EXIT_PROJECT$WARNING",
MODEL_SELECTOR$VERIFIED = "MODEL_SELECTOR$VERIFIED",
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
@@ -556,4 +558,24 @@ export enum I18nKey {
TIPS$PROTIP = "TIPS$PROTIP",
FEEDBACK$SUBMITTING_LABEL = "FEEDBACK$SUBMITTING_LABEL",
FEEDBACK$SUBMITTING_MESSAGE = "FEEDBACK$SUBMITTING_MESSAGE",
SETTINGS$NAV_USER = "SETTINGS$NAV_USER",
SETTINGS$USER_TITLE = "SETTINGS$USER_TITLE",
SETTINGS$USER_EMAIL = "SETTINGS$USER_EMAIL",
SETTINGS$USER_EMAIL_LOADING = "SETTINGS$USER_EMAIL_LOADING",
SETTINGS$SAVE = "SETTINGS$SAVE",
SETTINGS$EMAIL_SAVED_SUCCESSFULLY = "SETTINGS$EMAIL_SAVED_SUCCESSFULLY",
SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY = "SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY",
SETTINGS$FAILED_TO_SAVE_EMAIL = "SETTINGS$FAILED_TO_SAVE_EMAIL",
SETTINGS$SENDING = "SETTINGS$SENDING",
SETTINGS$VERIFICATION_EMAIL_SENT = "SETTINGS$VERIFICATION_EMAIL_SENT",
SETTINGS$EMAIL_VERIFICATION_REQUIRED = "SETTINGS$EMAIL_VERIFICATION_REQUIRED",
SETTINGS$INVALID_EMAIL_FORMAT = "SETTINGS$INVALID_EMAIL_FORMAT",
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
FEEDBACK$RATE_RESPONSE = "FEEDBACK$RATE_RESPONSE",
FEEDBACK$SELECT_REASON = "FEEDBACK$SELECT_REASON",
FEEDBACK$REASON_NOT_FOLLOW_INSTRUCTION = "FEEDBACK$REASON_NOT_FOLLOW_INSTRUCTION",
FEEDBACK$REASON_BAD_SOLUTION = "FEEDBACK$REASON_BAD_SOLUTION",
FEEDBACK$REASON_LACKS_ACCESS = "FEEDBACK$REASON_LACKS_ACCESS",
}
+352
View File
@@ -8207,6 +8207,38 @@
"de": "Wir schätzen Ihr Feedback. Bitte teilen Sie uns Ihre Gedanken mit.",
"uk": "Ми цінуємо ваш відгук. Будь ласка, поділіться з нами своїми думками."
},
"FEEDBACK$POSITIVE_SENT": {
"en": "Positive feedback sent",
"ja": "ポジティブなフィードバックが送信されました",
"zh-CN": "已发送积极反馈",
"zh-TW": "已發送積極反饋",
"ko-KR": "긍정적인 피드백이 전송되었습니다",
"no": "Positiv tilbakemelding sendt",
"ar": "تم إرسال تعليق إيجابي",
"de": "Positives Feedback gesendet",
"fr": "Commentaire positif envoyé",
"it": "Feedback positivo inviato",
"pt": "Feedback positivo enviado",
"es": "Comentario positivo enviado",
"tr": "Olumlu geri bildirim gönderildi",
"uk": "Позитивний відгук надіслано"
},
"FEEDBACK$NEGATIVE_SENT": {
"en": "Negative feedback sent",
"ja": "ネガティブなフィードバックが送信されました",
"zh-CN": "已发送消极反馈",
"zh-TW": "已發送消極反饋",
"ko-KR": "부정적인 피드백이 전송되었습니다",
"no": "Negativ tilbakemelding sendt",
"ar": "تم إرسال تعليق سلبي",
"de": "Negatives Feedback gesendet",
"fr": "Commentaire négatif envoyé",
"it": "Feedback negativo inviato",
"pt": "Feedback negativo enviado",
"es": "Comentario negativo enviado",
"tr": "Olumsuz geri bildirim gönderildi",
"uk": "Негативний відгук надіслано"
},
"EXIT_PROJECT$WARNING": {
"en": "Are you sure you want to exit this project? Any unsaved changes will be lost.",
"ja": "このプロジェクトを終了してもよろしいですか?保存されていない変更は失われます。",
@@ -8894,5 +8926,325 @@
"tr": "Geri bildirim gönderiliyor, lütfen bekleyin...",
"de": "Feedback senden, bitte warten...",
"uk": "Відправляємо відгук, будь ласка, почекайте..."
},
"SETTINGS$NAV_USER": {
"en": "User",
"ja": "ユーザー",
"zh-CN": "用户",
"zh-TW": "用戶",
"ko-KR": "사용자",
"no": "Bruker",
"it": "Utente",
"pt": "Usuário",
"es": "Usuario",
"ar": "المستخدم",
"fr": "Utilisateur",
"tr": "Kullanıcı",
"de": "Benutzer",
"uk": "Користувач"
},
"SETTINGS$USER_TITLE": {
"en": "User Information",
"ja": "ユーザー情報",
"zh-CN": "用户信息",
"zh-TW": "用戶信息",
"ko-KR": "사용자 정보",
"no": "Brukerinformasjon",
"it": "Informazioni utente",
"pt": "Informações do usuário",
"es": "Información del usuario",
"ar": "معلومات المستخدم",
"fr": "Informations utilisateur",
"tr": "Kullanıcı Bilgileri",
"de": "Benutzerinformationen",
"uk": "Інформація про користувача"
},
"SETTINGS$USER_EMAIL": {
"en": "Email",
"ja": "メール",
"zh-CN": "邮箱",
"zh-TW": "郵箱",
"ko-KR": "이메일",
"no": "E-post",
"it": "Email",
"pt": "Email",
"es": "Correo electrónico",
"ar": "البريد الإلكتروني",
"fr": "Email",
"tr": "E-posta",
"de": "E-Mail",
"uk": "Електронна пошта"
},
"SETTINGS$USER_EMAIL_LOADING": {
"en": "Loading...",
"ja": "読み込み中...",
"zh-CN": "加载中...",
"zh-TW": "加載中...",
"ko-KR": "로딩 중...",
"no": "Laster...",
"it": "Caricamento...",
"pt": "Carregando...",
"es": "Cargando...",
"ar": "جار التحميل...",
"fr": "Chargement...",
"tr": "Yükleniyor...",
"de": "Wird geladen...",
"uk": "Завантаження..."
},
"SETTINGS$SAVE": {
"en": "Save",
"ja": "保存",
"zh-CN": "保存",
"zh-TW": "儲存",
"ko-KR": "저장",
"no": "Lagre",
"it": "Salva",
"pt": "Salvar",
"es": "Guardar",
"ar": "حفظ",
"fr": "Enregistrer",
"tr": "Kaydet",
"de": "Speichern",
"uk": "Зберегти"
},
"SETTINGS$EMAIL_SAVED_SUCCESSFULLY": {
"en": "Email saved successfully",
"ja": "メールが正常に保存されました",
"zh-CN": "邮箱保存成功",
"zh-TW": "郵箱儲存成功",
"ko-KR": "이메일이 성공적으로 저장되었습니다",
"no": "E-post lagret",
"it": "Email salvata con successo",
"pt": "Email salvo com sucesso",
"es": "Correo electrónico guardado con éxito",
"ar": "تم حفظ البريد الإلكتروني بنجاح",
"fr": "Email enregistré avec succès",
"tr": "E-posta başarıyla kaydedildi",
"de": "E-Mail erfolgreich gespeichert",
"uk": "Електронну пошту успішно збережено"
},
"SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY": {
"en": "Your email has been verified successfully!",
"ja": "メールアドレスの確認が完了しました!",
"zh-CN": "您的邮箱已成功验证!",
"zh-TW": "您的郵箱已成功驗證!",
"ko-KR": "이메일이 성공적으로 인증되었습니다!",
"no": "E-posten din er bekreftet!",
"it": "La tua email è stata verificata con successo!",
"pt": "Seu email foi verificado com sucesso!",
"es": "¡Tu correo electrónico ha sido verificado con éxito!",
"ar": "تم التحقق من بريدك الإلكتروني بنجاح!",
"fr": "Votre email a été vérifié avec succès !",
"tr": "E-postanız başarıyla doğrulandı!",
"de": "Ihre E-Mail wurde erfolgreich verifiziert!",
"uk": "Вашу електронну пошту успішно підтверджено!"
},
"SETTINGS$FAILED_TO_SAVE_EMAIL": {
"en": "Failed to save email",
"ja": "メールの保存に失敗しました",
"zh-CN": "保存邮箱失败",
"zh-TW": "儲存郵箱失敗",
"ko-KR": "이메일 저장 실패",
"no": "Kunne ikke lagre e-post",
"it": "Impossibile salvare l'email",
"pt": "Falha ao salvar email",
"es": "Error al guardar el correo electrónico",
"ar": "فشل في حفظ البريد الإلكتروني",
"fr": "Échec de l'enregistrement de l'email",
"tr": "E-posta kaydedilemedi",
"de": "E-Mail konnte nicht gespeichert werden",
"uk": "Не вдалося зберегти електронну пошту"
},
"SETTINGS$SENDING": {
"en": "Sending",
"ja": "送信中",
"zh-CN": "发送中",
"zh-TW": "發送中",
"ko-KR": "전송 중",
"no": "Sender",
"it": "Invio in corso",
"pt": "Enviando",
"es": "Enviando",
"ar": "جاري الإرسال",
"fr": "Envoi en cours",
"tr": "Gönderiliyor",
"de": "Wird gesendet",
"uk": "Надсилання"
},
"SETTINGS$VERIFICATION_EMAIL_SENT": {
"en": "Verification email sent",
"ja": "確認メールを送信しました",
"zh-CN": "验证邮件已发送",
"zh-TW": "驗證郵件已發送",
"ko-KR": "인증 이메일이 전송되었습니다",
"no": "Bekreftelsese-post sendt",
"it": "Email di verifica inviata",
"pt": "Email de verificação enviado",
"es": "Correo de verificación enviado",
"ar": "تم إرسال بريد التحقق",
"fr": "Email de vérification envoyé",
"tr": "Doğrulama e-postası gönderildi",
"de": "Bestätigungs-E-Mail gesendet",
"uk": "Лист підтвердження надіслано"
},
"SETTINGS$EMAIL_VERIFICATION_REQUIRED": {
"en": "You must verify your email address before using All Hands",
"ja": "All Handsを使用する前にメールアドレスを確認する必要があります",
"zh-CN": "使用All Hands前,您必须验证您的电子邮件地址",
"zh-TW": "使用All Hands前,您必須驗證您的電子郵件地址",
"ko-KR": "All Hands를 사용하기 전에 이메일 주소를 확인해야 합니다",
"no": "Du må bekrefte e-postadressen din før du bruker All Hands",
"it": "Devi verificare il tuo indirizzo email prima di utilizzare All Hands",
"pt": "Você deve verificar seu endereço de e-mail antes de usar o All Hands",
"es": "Debe verificar su dirección de correo electrónico antes de usar All Hands",
"ar": "يجب عليك التحقق من عنوان بريدك الإلكتروني قبل استخدام All Hands",
"fr": "Vous devez vérifier votre adresse e-mail avant d'utiliser All Hands",
"tr": "All Hands'i kullanmadan önce e-posta adresinizi doğrulamanız gerekiyor",
"de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können",
"uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands"
},
"SETTINGS$INVALID_EMAIL_FORMAT": {
"en": "Please enter a valid email address",
"ja": "有効なメールアドレスを入力してください",
"zh-CN": "请输入有效的电子邮件地址",
"zh-TW": "請輸入有效的電子郵件地址",
"ko-KR": "유효한 이메일 주소를 입력하세요",
"no": "Vennligst skriv inn en gyldig e-postadresse",
"it": "Inserisci un indirizzo email valido",
"pt": "Por favor, insira um endereço de e-mail válido",
"es": "Por favor, introduzca una dirección de correo electrónico válida",
"ar": "الرجاء إدخال عنوان بريد إلكتروني صالح",
"fr": "Veuillez entrer une adresse e-mail valide",
"tr": "Lütfen geçerli bir e-posta adresi girin",
"de": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"uk": "Будь ласка, введіть дійсну електронну адресу"
},
"SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE": {
"en": "Your access is limited until your email is verified. You can only access this settings page.",
"ja": "メールが確認されるまでアクセスが制限されています。この設定ページにのみアクセスできます。",
"zh-CN": "在验证您的电子邮件之前,您的访问权限受到限制。您只能访问此设置页面。",
"zh-TW": "在驗證您的電子郵件之前,您的訪問權限受到限制。您只能訪問此設置頁面。",
"ko-KR": "이메일이 확인될 때까지 액세스가 제한됩니다. 이 설정 페이지만 액세스할 수 있습니다.",
"no": "Din tilgang er begrenset til e-posten din er bekreftet. Du kan bare få tilgang til denne innstillingssiden.",
"it": "Il tuo accesso è limitato fino a quando la tua email non viene verificata. Puoi accedere solo a questa pagina delle impostazioni.",
"pt": "Seu acesso é limitado até que seu e-mail seja verificado. Você só pode acessar esta página de configurações.",
"es": "Su acceso es limitado hasta que se verifique su correo electrónico. Solo puede acceder a esta página de configuración.",
"ar": "وصولك محدود حتى يتم التحقق من بريدك الإلكتروني. يمكنك فقط الوصول إلى صفحة الإعدادات هذه.",
"fr": "Votre accès est limité jusqu'à ce que votre e-mail soit vérifié. Vous ne pouvez accéder qu'à cette page de paramètres.",
"tr": "E-postanız doğrulanana kadar erişiminiz sınırlıdır. Yalnızca bu ayarlar sayfasına erişebilirsiniz.",
"de": "Ihr Zugriff ist eingeschränkt, bis Ihre E-Mail-Adresse bestätigt wurde. Sie können nur auf diese Einstellungsseite zugreifen.",
"uk": "Ваш доступ обмежений, доки ваша електронна пошта не буде підтверджена. Ви можете отримати доступ лише до цієї сторінки налаштувань."
},
"SETTINGS$RESEND_VERIFICATION": {
"en": "Resend verification",
"ja": "確認メールを再送信",
"zh-CN": "重新发送验证",
"zh-TW": "重新發送驗證",
"ko-KR": "인증 재전송",
"no": "Send bekreftelse på nytt",
"it": "Rinvia verifica",
"pt": "Reenviar verificação",
"es": "Reenviar verificación",
"ar": "إعادة إرسال التحقق",
"fr": "Renvoyer la vérification",
"tr": "Doğrulamayı yeniden gönder",
"de": "Bestätigung erneut senden",
"uk": "Надіслати підтвердження повторно"
},
"SETTINGS$FAILED_TO_RESEND_VERIFICATION": {
"en": "Failed to resend verification email",
"ja": "確認メールの再送信に失敗しました",
"zh-CN": "重新发送验证邮件失败",
"zh-TW": "重新發送驗證郵件失敗",
"ko-KR": "인증 이메일 재전송 실패",
"no": "Kunne ikke sende bekreftelsese-post på nytt",
"it": "Impossibile rinviare l'email di verifica",
"pt": "Falha ao reenviar email de verificação",
"es": "Error al reenviar el correo de verificación",
"ar": "فشل في إعادة إرسال بريد التحقق",
"fr": "Échec du renvoi de l'email de vérification",
"tr": "Doğrulama e-postası yeniden gönderilemedi",
"de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden",
"uk": "Не вдалося повторно надіслати лист підтвердження"
},
"FEEDBACK$RATE_RESPONSE": {
"en": "Rate this response:",
"de": "Bewerten Sie diese Antwort:",
"it": "Valuta questa risposta:",
"pt": "Avalie esta resposta:",
"es": "Califica esta respuesta:",
"ja": "この回答を評価してください:",
"zh-CN": "评价此回复:",
"zh-TW": "評價此回覆:",
"ko-KR": "이 응답을 평가하세요:",
"no": "Vurder dette svaret:",
"ar": "قيم هذه الإجابة:",
"fr": "Évaluez cette réponse:",
"tr": "Bu yanıtı değerlendirin:",
"uk": "Оцініть цю відповідь:"
},
"FEEDBACK$SELECT_REASON": {
"en": "Please select a reason:",
"de": "Bitte wählen Sie einen Grund:",
"it": "Seleziona un motivo:",
"pt": "Por favor, selecione um motivo:",
"es": "Por favor, seleccione un motivo:",
"ja": "理由を選択してください:",
"zh-CN": "请选择原因:",
"zh-TW": "請選擇原因:",
"ko-KR": "이유를 선택해 주세요:",
"no": "Vennligst velg en grunn:",
"ar": "الرجاء اختيار سبب:",
"fr": "Veuillez sélectionner une raison:",
"tr": "Lütfen bir neden seçin:",
"uk": "Будь ласка, виберіть причину:"
},
"FEEDBACK$REASON_NOT_FOLLOW_INSTRUCTION": {
"en": "The agent did not follow my instruction",
"de": "Der Agent hat meine Anweisung nicht befolgt",
"it": "L'agente non ha seguito le mie istruzioni",
"pt": "O agente não seguiu minhas instruções",
"es": "El agente no siguió mis instrucciones",
"ja": "エージェントが私の指示に従わなかった",
"zh-CN": "代理未遵循我的指示",
"zh-TW": "代理未遵循我的指示",
"ko-KR": "에이전트가 내 지시를 따르지 않았습니다",
"no": "Agenten fulgte ikke instruksjonene mine",
"ar": "لم يتبع الوكيل تعليماتي",
"fr": "L'agent n'a pas suivi mes instructions",
"tr": "Ajan talimatlarımı takip etmedi",
"uk": "Агент не дотримувався моїх інструкцій"
},
"FEEDBACK$REASON_BAD_SOLUTION": {
"en": "The agent did not implement a good solution",
"de": "Der Agent hat keine gute Lösung implementiert",
"it": "L'agente non ha implementato una buona soluzione",
"pt": "O agente não implementou uma boa solução",
"es": "El agente no implementó una buena solución",
"ja": "エージェントが良い解決策を実装しなかった",
"zh-CN": "代理未实现良好的解决方案",
"zh-TW": "代理未實現良好的解決方案",
"ko-KR": "에이전트가 좋은 해결책을 구현하지 않았습니다",
"no": "Agenten implementerte ikke en god løsning",
"ar": "لم ينفذ الوكيل حلاً جيدًا",
"fr": "L'agent n'a pas implémenté une bonne solution",
"tr": "Ajan iyi bir çözüm uygulamadı",
"uk": "Агент не реалізував хороше рішення"
},
"FEEDBACK$REASON_LACKS_ACCESS": {
"en": "The agent lacks access to software or hardware that is not installable in the runtime to complete the task",
"de": "Dem Agenten fehlt der Zugriff auf Software oder Hardware, die in der Laufzeitumgebung nicht installierbar ist, um die Aufgabe zu erledigen",
"it": "L'agente non ha accesso a software o hardware non installabile nel runtime per completare l'attività",
"pt": "O agente não tem acesso a software ou hardware que não é instalável no tempo de execução para concluir a tarefa",
"es": "El agente no tiene acceso a software o hardware que no se puede instalar en el entorno de ejecución para completar la tarea",
"ja": "エージェントはタスクを完了するためにランタイムにインストールできないソフトウェアまたはハードウェアへのアクセスが不足しています",
"zh-CN": "代理缺乏访问无法在运行时安装的软件或硬件来完成任务",
"zh-TW": "代理缺乏訪問無法在運行時安裝的軟件或硬件來完成任務",
"ko-KR": "에이전트는 런타임에 설치할 수 없는 소프트웨어나 하드웨어에 접근할 수 없어 작업을 완료할 수 없습니다",
"no": "Agenten mangler tilgang til programvare eller maskinvare som ikke kan installeres i kjøretidsmiljøet for å fullføre oppgaven",
"ar": "يفتقر الوكيل إلى الوصول إلى البرامج أو الأجهزة التي لا يمكن تثبيتها في وقت التشغيل لإكمال المهمة",
"fr": "L'agent n'a pas accès à des logiciels ou du matériel qui ne peuvent pas être installés dans l'environnement d'exécution pour accomplir la tâche",
"tr": "Ajan, görevi tamamlamak için çalışma zamanında yüklenemeyen yazılım veya donanıma erişim eksikliği yaşıyor",
"uk": "Агент не має доступу до програмного або апаратного забезпечення, яке неможливо встановити в середовищі виконання для виконання завдання"
}
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L439.5 329 543.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 477 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L433.6 328.4l26.2 155.6c1.5 9-2.2 18.1-9.6 23.5s-17.3 6-25.3 1.7l-137-73.2L151 509.1c-8.1 4.3-17.9 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l26.2-155.6L31.1 218.2c-6.5-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6L266.3 13.5C270.4 5.2 278.7 0 287.9 0zm0 79L235.4 187.2c-3.5 7.1-10.2 12.1-18.1 13.3L99 217.9 184.9 303c5.5 5.5 8.1 13.3 6.8 21L171.4 443.7l105.2-56.2c7.1-3.8 15.6-3.8 22.6 0l105.2 56.2L384.2 324.1c-1.3-7.7 1.2-15.5 6.8-21l85.9-85.1L358.6 200.5c-7.8-1.2-14.6-6.1-18.1-13.3L287.9 79z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 733 B

+1
View File
@@ -12,6 +12,7 @@ export type Message = {
pending?: boolean;
translationID?: string;
eventID?: number;
feedback?: "positive" | "negative" | null;
observation?: PayloadAction<OpenHandsObservation>;
action?: PayloadAction<OpenHandsAction>;
};
+1
View File
@@ -12,6 +12,7 @@ export default [
route("settings", "routes/settings.tsx", [
index("routes/llm-settings.tsx"),
route("mcp", "routes/mcp-settings.tsx"),
route("user", "routes/user-settings.tsx"),
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),
+4 -1
View File
@@ -25,6 +25,7 @@ import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
export function ErrorBoundary() {
const error = useRouteError();
@@ -204,7 +205,9 @@ export default function MainApp() {
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
<Outlet />
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
</div>
{renderAuthModal && (
+3 -1
View File
@@ -15,6 +15,7 @@ function SettingsScreen() {
const isSaas = config?.APP_MODE === "saas";
const saasNavItems = [
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
@@ -33,10 +34,11 @@ function SettingsScreen() {
React.useEffect(() => {
if (isSaas) {
if (pathname === "/settings") {
navigate("/settings/git");
navigate("/settings/user");
}
} else {
const noEnteringPaths = [
"/settings/user",
"/settings/billing",
"/settings/credits",
"/settings/api-keys",
+229
View File
@@ -0,0 +1,229 @@
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
// Email validation regex pattern
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function EmailInputSection({
email,
onEmailChange,
onSaveEmail,
onResendVerification,
isSaving,
isResendingVerification,
isEmailChanged,
emailVerified,
isEmailValid,
children,
}: {
email: string;
onEmailChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSaveEmail: () => void;
onResendVerification: () => void;
isSaving: boolean;
isResendingVerification: boolean;
isEmailChanged: boolean;
emailVerified?: boolean;
isEmailValid: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm">{t("SETTINGS$USER_EMAIL")}</label>
<div className="flex items-center gap-3">
<input
type="email"
value={email}
onChange={onEmailChange}
className={`text-base text-white p-2 bg-base-tertiary rounded border ${
isEmailChanged && !isEmailValid
? "border-red-500"
: "border-tertiary"
} flex-grow focus:outline-none focus:border-transparent focus:ring-0`}
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
data-testid="email-input"
/>
</div>
{isEmailChanged && !isEmailValid && (
<div
className="text-red-500 text-sm mt-1"
data-testid="email-validation-error"
>
{t("SETTINGS$INVALID_EMAIL_FORMAT")}
</div>
)}
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={onSaveEmail}
disabled={!isEmailChanged || isSaving || !isEmailValid}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="save-email-button"
>
{isSaving ? t("SETTINGS$SAVING") : t("SETTINGS$SAVE")}
</button>
{emailVerified === false && (
<button
type="button"
onClick={onResendVerification}
disabled={isResendingVerification}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="resend-verification-button"
>
{isResendingVerification
? t("SETTINGS$SENDING")
: t("SETTINGS$RESEND_VERIFICATION")}
</button>
)}
</div>
{children}
</div>
</div>
);
}
function VerificationAlert() {
const { t } = useTranslation();
return (
<div
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mt-4"
role="alert"
>
<p className="font-bold">{t("SETTINGS$EMAIL_VERIFICATION_REQUIRED")}</p>
<p className="text-sm">
{t("SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE")}
</p>
</div>
);
}
// These components have been replaced with toast notifications
function UserSettingsScreen() {
const { t } = useTranslation();
const { data: settings, isLoading, refetch } = useSettings();
const [email, setEmail] = useState("");
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const [isEmailValid, setIsEmailValid] = useState(true);
const queryClient = useQueryClient();
const pollingIntervalRef = useRef<number | null>(null);
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
useEffect(() => {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(settings.EMAIL);
setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
}
}, [settings?.EMAIL]);
useEffect(() => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (
prevVerificationStatusRef.current === false &&
settings?.EMAIL_VERIFIED === true
) {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
}, 2000);
}
prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
if (settings?.EMAIL_VERIFIED === false) {
pollingIntervalRef.current = window.setInterval(() => {
refetch();
}, 5000);
}
return () => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value;
setEmail(newEmail);
setIsEmailValid(EMAIL_REGEX.test(newEmail));
};
const handleSaveEmail = async () => {
if (email === originalEmail || !isEmailValid) return;
try {
setIsSaving(true);
await openHands.post("/api/email", { email }, { withCredentials: true });
setOriginalEmail(email);
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);
} finally {
setIsSaving(false);
}
};
const handleResendVerification = async () => {
try {
setIsResendingVerification(true);
await openHands.put("/api/email/verify", {}, { withCredentials: true });
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$VERIFICATION_EMAIL_SENT"));
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_RESEND_VERIFICATION"), error);
} finally {
setIsResendingVerification(false);
}
};
const isEmailChanged = email !== originalEmail;
return (
<div data-testid="user-settings-screen" className="flex flex-col h-full">
<div className="p-9 flex flex-col gap-6">
{isLoading ? (
<div className="animate-pulse h-8 w-64 bg-tertiary rounded" />
) : (
<EmailInputSection
email={email}
onEmailChange={handleEmailChange}
onSaveEmail={handleSaveEmail}
onResendVerification={handleResendVerification}
isSaving={isSaving}
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
emailVerified={settings?.EMAIL_VERIFIED}
isEmailValid={isEmailValid}
>
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
</EmailInputSection>
)}
</div>
</div>
);
}
export default UserSettingsScreen;
+20
View File
@@ -11,3 +11,23 @@ export function createChatMessage(
};
return event;
}
export function createUserFeedback(
feedbackType: "positive" | "negative",
targetType: "message" | "trajectory",
targetId?: number,
rating?: number,
reason?: string | null,
) {
const event = {
action: ActionType.USER_FEEDBACK,
args: {
feedback_type: feedbackType,
target_type: targetType,
target_id: targetId,
rating,
reason,
},
};
return event;
}
+2
View File
@@ -19,6 +19,8 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
SEARCH_API_KEY: "",
IS_NEW_USER: true,
EMAIL: "",
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
MCP_CONFIG: {
sse_servers: [],
stdio_servers: [],
+398
View File
@@ -0,0 +1,398 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { Message } from "#/message";
import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsEventType } from "#/types/core/base";
import {
CommandObservation,
IPythonObservation,
OpenHandsObservation,
RecallObservation,
} from "#/types/core/observations";
type SliceState = {
messages: Message[];
systemMessage: {
content: string;
tools: Array<Record<string, unknown>> | null;
openhands_version: string | null;
agent_class: string | null;
} | null;
};
const MAX_CONTENT_LENGTH = 1000;
const HANDLED_ACTIONS: OpenHandsEventType[] = [
"run",
"run_ipython",
"write",
"read",
"browse",
"browse_interactive",
"edit",
"user_feedback",
"recall",
"think",
"system",
"call_tool_mcp",
"mcp",
];
function getRiskText(risk: ActionSecurityRisk) {
switch (risk) {
case ActionSecurityRisk.LOW:
return "Low Risk";
case ActionSecurityRisk.MEDIUM:
return "Medium Risk";
case ActionSecurityRisk.HIGH:
return "High Risk";
case ActionSecurityRisk.UNKNOWN:
default:
return "Unknown Risk";
}
}
const initialState: SliceState = {
messages: [],
systemMessage: null,
};
export const chatSlice = createSlice({
name: "chat",
initialState,
reducers: {
addUserMessage(
state,
action: PayloadAction<{
content: string;
imageUrls: string[];
timestamp: string;
pending?: boolean;
}>,
) {
const message: Message = {
type: "thought",
sender: "user",
content: action.payload.content,
imageUrls: action.payload.imageUrls,
timestamp: action.payload.timestamp || new Date().toISOString(),
pending: !!action.payload.pending,
};
// Remove any pending messages
let i = state.messages.length;
while (i) {
i -= 1;
const m = state.messages[i] as Message;
if (m.pending) {
state.messages.splice(i, 1);
}
}
state.messages.push(message);
},
addAssistantMessage(state: SliceState, action: PayloadAction<string>) {
const message: Message = {
type: "thought",
sender: "assistant",
content: action.payload,
imageUrls: [],
timestamp: new Date().toISOString(),
pending: false,
};
state.messages.push(message);
},
addAssistantAction(
state: SliceState,
action: PayloadAction<OpenHandsAction>,
) {
const actionID = action.payload.action;
if (!HANDLED_ACTIONS.includes(actionID)) {
return;
}
const translationID = `ACTION_MESSAGE$${actionID.toUpperCase()}`;
let text = "";
if (actionID === "system") {
// Store the system message in the state
state.systemMessage = {
content: action.payload.args.content,
tools: action.payload.args.tools,
openhands_version: action.payload.args.openhands_version,
agent_class: action.payload.args.agent_class,
};
// Don't add a message for system actions
return;
}
if (actionID === "run") {
text = `Command:\n\`${action.payload.args.command}\``;
} else if (actionID === "run_ipython") {
text = `\`\`\`\n${action.payload.args.code}\n\`\`\``;
} else if (actionID === "write") {
let { content } = action.payload.args;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
text = `${action.payload.args.path}\n${content}`;
} else if (actionID === "browse") {
text = `Browsing ${action.payload.args.url}`;
} else if (actionID === "browse_interactive") {
// Include the browser_actions in the content
text = `**Action:**\n\n\`\`\`python\n${action.payload.args.browser_actions}\n\`\`\``;
} else if (actionID === "recall") {
// skip recall actions
return;
} else if (actionID === "call_tool_mcp") {
// Format MCP action with name and arguments
const name = action.payload.args.name || "";
const args = action.payload.args.arguments || {};
text = `**MCP Tool Call:** ${name}\n\n`;
// Include thought if available
if (action.payload.args.thought) {
text += `\n\n**Thought:**\n${action.payload.args.thought}`;
}
text += `\n\n**Arguments:**\n\`\`\`json\n${JSON.stringify(args, null, 2)}\n\`\`\``;
}
if (actionID === "run" || actionID === "run_ipython") {
if (
action.payload.args.confirmation_state === "awaiting_confirmation"
) {
text += `\n\n${getRiskText(action.payload.args.security_risk as unknown as ActionSecurityRisk)}`;
}
} else if (actionID === "think") {
text = action.payload.args.thought;
}
const message: Message = {
type: "action",
sender: "assistant",
translationID,
eventID: action.payload.id,
content: text,
imageUrls: [],
timestamp: new Date().toISOString(),
action,
};
state.messages.push(message);
},
addAssistantObservation(
state: SliceState,
observation: PayloadAction<OpenHandsObservation>,
) {
const observationID = observation.payload.observation;
if (!HANDLED_ACTIONS.includes(observationID)) {
return;
}
// Special handling for RecallObservation - create a new message instead of updating an existing one
if (observationID === "recall") {
const recallObs = observation.payload as RecallObservation;
let content = ``;
// Handle workspace context
if (recallObs.extras.recall_type === "workspace_context") {
if (recallObs.extras.repo_name) {
content += `\n\n**Repository:** ${recallObs.extras.repo_name}`;
}
if (recallObs.extras.repo_directory) {
content += `\n\n**Directory:** ${recallObs.extras.repo_directory}`;
}
if (recallObs.extras.date) {
content += `\n\n**Date:** ${recallObs.extras.date}`;
}
if (
recallObs.extras.runtime_hosts &&
Object.keys(recallObs.extras.runtime_hosts).length > 0
) {
content += `\n\n**Available Hosts**`;
for (const [host, port] of Object.entries(
recallObs.extras.runtime_hosts,
)) {
content += `\n\n- ${host} (port ${port})`;
}
}
if (
recallObs.extras.custom_secrets_descriptions &&
Object.keys(recallObs.extras.custom_secrets_descriptions).length > 0
) {
content += `\n\n**Custom Secrets**`;
for (const [name, description] of Object.entries(
recallObs.extras.custom_secrets_descriptions,
)) {
content += `\n\n- $${name}: ${description}`;
}
}
if (recallObs.extras.repo_instructions) {
content += `\n\n**Repository Instructions:**\n\n${recallObs.extras.repo_instructions}`;
}
if (recallObs.extras.additional_agent_instructions) {
content += `\n\n**Additional Instructions:**\n\n${recallObs.extras.additional_agent_instructions}`;
}
}
// Create a new message for the observation
// Use the correct translation ID format that matches what's in the i18n file
const translationID = `OBSERVATION_MESSAGE$${observationID.toUpperCase()}`;
// Handle microagent knowledge
if (
recallObs.extras.microagent_knowledge &&
recallObs.extras.microagent_knowledge.length > 0
) {
content += `\n\n**Triggered Microagent Knowledge:**`;
for (const knowledge of recallObs.extras.microagent_knowledge) {
content += `\n\n- **${knowledge.name}** (triggered by keyword: ${knowledge.trigger})\n\n\`\`\`\n${knowledge.content}\n\`\`\``;
}
}
const message: Message = {
type: "action",
sender: "assistant",
translationID,
eventID: observation.payload.id,
content,
imageUrls: [],
timestamp: new Date().toISOString(),
success: true,
};
state.messages.push(message);
return; // Skip the normal observation handling below
}
// Normal handling for other observation types
const translationID = `OBSERVATION_MESSAGE$${observationID.toUpperCase()}`;
const causeID = observation.payload.cause;
const causeMessage = state.messages.find(
(message) => message.eventID === causeID,
);
if (!causeMessage) {
return;
}
causeMessage.translationID = translationID;
causeMessage.observation = observation;
// Set success property based on observation type
if (observationID === "run") {
const commandObs = observation.payload as CommandObservation;
// If exit_code is -1, it means the command timed out, so we set success to undefined
// to not show any status indicator
if (commandObs.extras.metadata.exit_code === -1) {
causeMessage.success = undefined;
} else {
causeMessage.success = commandObs.extras.metadata.exit_code === 0;
}
} else if (observationID === "run_ipython") {
// For IPython, we consider it successful if there's no error message
const ipythonObs = observation.payload as IPythonObservation;
causeMessage.success = !ipythonObs.content
.toLowerCase()
.includes("error:");
} else if (observationID === "read" || observationID === "edit") {
// For read/edit operations, we consider it successful if there's content and no error
if (observation.payload.extras.impl_source === "oh_aci") {
causeMessage.success =
observation.payload.content.length > 0 &&
!observation.payload.content.startsWith("ERROR:\n");
} else {
causeMessage.success =
observation.payload.content.length > 0 &&
!observation.payload.content.toLowerCase().includes("error:");
}
}
if (observationID === "run" || observationID === "run_ipython") {
let { content } = observation.payload;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
content = `${causeMessage.content}\n\nOutput:\n\`\`\`\n${content.trim() || "[Command finished execution with no output]"}\n\`\`\``;
causeMessage.content = content; // Observation content includes the action
} else if (observationID === "read") {
causeMessage.content = `\`\`\`\n${observation.payload.content}\n\`\`\``; // Content is already truncated by the ACI
} else if (observationID === "edit") {
if (causeMessage.success) {
causeMessage.content = `\`\`\`diff\n${observation.payload.extras.diff}\n\`\`\``; // Content is already truncated by the ACI
} else {
causeMessage.content = observation.payload.content;
}
} else if (observationID === "browse") {
let content = `**URL:** ${observation.payload.extras.url}\n`;
if (observation.payload.extras.error) {
content += `\n\n**Error:**\n${observation.payload.extras.error}\n`;
}
content += `\n\n**Output:**\n${observation.payload.content}`;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
}
causeMessage.content = content;
} else if (observationID === "mcp") {
// For MCP observations, we want to show the content as formatted output
// similar to how run/run_ipython actions are handled
let { content } = observation.payload;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
content = `${causeMessage.content}\n\n**Output:**\n\`\`\`\n${content.trim() || "[MCP Tool finished execution with no output]"}\n\`\`\``;
causeMessage.content = content; // Observation content includes the action
// Set success based on whether there's an error message
causeMessage.success = !observation.payload.content
.toLowerCase()
.includes("error:");
}
},
addErrorMessage(
state: SliceState,
action: PayloadAction<{ id?: string; message: string }>,
) {
const { id, message } = action.payload;
state.messages.push({
translationID: id,
content: message,
type: "error",
sender: "assistant",
timestamp: new Date().toISOString(),
});
},
clearMessages(state: SliceState) {
state.messages = [];
state.systemMessage = null;
},
setMessageFeedback(
state: SliceState,
action: PayloadAction<{
messageId: number;
feedbackType: "positive" | "negative";
}>,
) {
const { messageId, feedbackType } = action.payload;
const messageIndex = state.messages.findIndex(
(message) => message.eventID === messageId,
);
if (messageIndex !== -1) {
state.messages[messageIndex].feedback = feedbackType;
}
},
},
});
export const {
addUserMessage,
addAssistantMessage,
addAssistantAction,
addAssistantObservation,
addErrorMessage,
clearMessages,
setMessageFeedback,
} = chatSlice.actions;
// Selectors
export const selectSystemMessage = (state: { chat: SliceState }) =>
state.chat.systemMessage;
export default chatSlice.reducer;
+3
View File
@@ -42,6 +42,9 @@ enum ActionType {
// Changes the state of the agent, e.g. to paused or running
CHANGE_AGENT_STATE = "change_agent_state",
// User feedback on messages or the entire trajectory
USER_FEEDBACK = "user_feedback",
// Interact with the MCP server.
MCP = "call_tool_mcp",
}
+13
View File
@@ -143,6 +143,18 @@ export interface RejectAction extends OpenHandsActionEvent<"reject"> {
};
}
export interface UserFeedbackAction
extends OpenHandsActionEvent<"user_feedback"> {
source: "user";
args: {
feedback_type: "positive" | "negative";
target_type: "message" | "trajectory";
target_id?: number; // Event ID for message feedback, null for trajectory feedback
rating?: number; // 1-5 rating for SAAS mode
reason?: string | null; // Reason for the rating in SAAS mode
};
}
export interface RecallAction extends OpenHandsActionEvent<"recall"> {
source: "agent";
args: {
@@ -176,5 +188,6 @@ export type OpenHandsAction =
| FileEditAction
| FileWriteAction
| RejectAction
| UserFeedbackAction
| RecallAction
| MCPAction;
+1
View File
@@ -15,6 +15,7 @@ export type OpenHandsEventType =
| "think"
| "finish"
| "error"
| "user_feedback"
| "recall"
| "mcp"
| "call_tool_mcp"
+4
View File
@@ -45,6 +45,8 @@ export type Settings = {
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
MCP_CONFIG?: MCPConfig;
EMAIL?: string;
EMAIL_VERIFIED?: boolean;
};
export type ApiSettings = {
@@ -68,6 +70,8 @@ export type ApiSettings = {
sse_servers: (string | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];
};
email?: string;
email_verified?: boolean;
};
export type PostSettings = Settings & {
+1 -1
View File
@@ -30,7 +30,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$GITHUB_HOOK,
link: "https://docs.all-hands.dev/usage/cloud/cloud-issue-resolver",
link: "https://docs.all-hands.dev/usage/cloud/github-installation#working-on-github-issues-and-pull-requests-using-openhands",
},
{
key: I18nKey.TIPS$BLOG_SIGNUP,
+3
View File
@@ -117,7 +117,10 @@ You can see an example of a repo agent in [the agent for the OpenHands repo itse
- Include repository structure details
- Specify testing and build procedures
- List environment requirements
- Document CI workflows and checks
- Include information about code quality standards
- Maintain up-to-date team practices
- Consider using OpenHands to generate a comprehensive repo.md (see [Creating a Repository Agent](#creating-a-repository-agent))
- YAML frontmatter is optional - files without frontmatter will be loaded with default settings
### Submission Process
+65
View File
@@ -0,0 +1,65 @@
---
name: add_repo_inst
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /add_repo_inst
inputs:
- name: REPO_FOLDER_NAME
description: "Branch for the agent to work on"
---
Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes (1) the purpose of this repository, (2) the general setup of this repo, and (3) a brief description of the structure of this repo.
Here's an example:
```markdown
---
name: repo
type: repo
agent: CodeActAgent
---
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).
## General Setup:
To set up the entire repo, including frontend and backend, run `make build`.
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
Before pushing any changes, you should ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
If either command fails, it may have automatically fixed some issues. You should fix any issues that weren't automatically fixed,
then re-run the command to ensure it passes.
## Repository Structure
Backend:
- Located in the `openhands` directory
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
- Write all tests with pytest
Frontend:
- Located in the `frontend` directory
- Prerequisites: A recent version of NodeJS / NPM
- Setup: Run `npm install` in the frontend directory
- Testing:
- Run tests: `npm run test`
- To run specific tests: `npm run test -- -t "TestName"`
- Building:
- Build for production: `npm run build`
- Environment Variables:
- Set in `frontend/.env` or as environment variables
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
- Internationalization:
- Generate i18n declaration file: `npm run make-i18n`
```
Now, please write a similar markdown for the current repository.
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
+19
View File
@@ -0,0 +1,19 @@
---
name: address_pr_comments
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /address_pr_comments
inputs:
- name: PR_URL
description: "URL of the pull request"
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
---
First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose.
This branch corresponds to this PR {{ PR_URL }}
Next, you should use the GitHub API to read the reviews and comments on this PR and address them.
+23
View File
@@ -0,0 +1,23 @@
---
name: fix_test
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /fix_test
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
- name: FUNCTION_TO_FIX
description: "The name of function to fix"
- name: FILE_FOR_FUNCTION
description: "The path of the file that contains the function"
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
PLEASE DO NOT modify the tests by yourself -- Let me know if you think some of the tests are incorrect.
+21
View File
@@ -0,0 +1,21 @@
---
name: update_pr_description
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /update_pr_description
inputs:
- name: PR_URL
description: "URL of the pull request"
type: string
validation:
pattern: "^https://github.com/.+/.+/pull/[0-9]+$"
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
type: string
---
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary.
+19
View File
@@ -0,0 +1,19 @@
---
name: update_test
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /update_test
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
The current implementation of the code is correct BUT the test functions {{ FUNCTION_TO_FIX }} in file {{ FILE_FOR_FUNCTION }} are failing.
Please update the test file so that they pass with the current version of the implementation.
+1 -31
View File
@@ -1,6 +1,7 @@
import asyncio
import logging
import os
import sys
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
@@ -453,37 +454,6 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
def main():
args = parse_arguments()
# Check if team command is used
if hasattr(args, 'command') and args.command == 'team':
# Import and run the team CLI directly
import sys
from openhands.cli.team import main as team_main
# Get arguments after 'team'
team_args = []
if len(sys.argv) > 2:
# Pass all arguments after 'team'
team_args = sys.argv[2:]
if not team_args:
# If no additional arguments, show help message
print('OpenHands Team CLI')
print('=================')
print('To use the team CLI, run one of the following commands:')
print(' openhands team list - List all conversations')
print(' openhands team create - Create a new conversation')
print(' openhands team join <id> - Join an existing conversation')
print()
print("For more information, run 'openhands team --help'")
return
# Run the team CLI with the arguments
team_main(team_args)
return
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
-549
View File
@@ -1,549 +0,0 @@
"""Team CLI interface for OpenHands.
This module provides a CLI interface for interacting with the OpenHands HTTP and WebSocket APIs.
It allows creating conversations and showing the current list of conversations/statuses.
"""
import argparse
import asyncio
import os
import sys
from datetime import datetime
from typing import Any, Optional
import aiohttp
import socketio
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.shortcuts import clear
from rich.console import Console
from rich.table import Table
from openhands.cli.tui import (
display_banner,
display_event,
display_welcome_message,
read_prompt_input,
)
from openhands.core.schema import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization import event_from_dict, event_to_dict
class TeamClient:
"""Client for interacting with the OpenHands HTTP and WebSocket APIs."""
def __init__(self, base_url: str, api_key: Optional[str] = None):
"""Initialize the TeamClient.
Args:
base_url: The base URL for the OpenHands API.
api_key: Optional API key for authentication.
"""
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.sio = socketio.AsyncClient()
self.console = Console()
self.headers = {}
if api_key:
self.headers['Authorization'] = f'Bearer {api_key}'
async def list_conversations(self, limit: int = 20) -> list[dict[str, Any]]:
"""List conversations.
Args:
limit: Maximum number of conversations to return.
Returns:
List of conversation objects.
"""
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.get(
f'{self.base_url}/api/conversations?limit={limit}'
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f'Failed to list conversations: {error_text}')
data = await response.json()
return data.get('results', [])
async def create_conversation(
self,
repository: Optional[str] = None,
git_provider: Optional[str] = None,
selected_branch: Optional[str] = None,
initial_user_msg: Optional[str] = None,
conversation_instructions: Optional[str] = None,
) -> str:
"""Create a new conversation.
Args:
repository: Optional repository name (owner/repo).
git_provider: Optional git provider (github or gitlab).
selected_branch: Optional branch name.
initial_user_msg: Optional initial user message.
conversation_instructions: Optional conversation instructions.
Returns:
The conversation ID.
"""
payload = {
'repository': repository,
'git_provider': git_provider,
'selected_branch': selected_branch,
'initial_user_msg': initial_user_msg,
'conversation_instructions': conversation_instructions,
}
# Remove None values
payload = {k: v for k, v in payload.items() if v is not None}
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.post(
f'{self.base_url}/api/conversations', json=payload
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f'Failed to create conversation: {error_text}')
data = await response.json()
return data.get('conversation_id')
async def get_conversation(self, conversation_id: str) -> dict[str, Any]:
"""Get conversation details.
Args:
conversation_id: The conversation ID.
Returns:
Conversation details.
"""
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.get(
f'{self.base_url}/api/conversations/{conversation_id}'
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f'Failed to get conversation: {error_text}')
return await response.json()
async def connect_to_conversation(
self, conversation_id: str, latest_event_id: int = -1
) -> None:
"""Connect to a conversation via WebSocket.
Args:
conversation_id: The conversation ID.
latest_event_id: The latest event ID to start from.
"""
# Set up event handlers
@self.sio.event
async def connect():
self.console.print('[green]Connected to conversation[/green]')
@self.sio.event
async def disconnect():
self.console.print('[yellow]Disconnected from conversation[/yellow]')
@self.sio.event
async def oh_event(data):
event = event_from_dict(data)
# Create a dummy config object to satisfy the type checker
from openhands.core.config import OpenHandsConfig
dummy_config = OpenHandsConfig()
display_event(event, dummy_config)
# Connect to the WebSocket
query = {
'conversation_id': conversation_id,
'latest_event_id': str(latest_event_id),
}
if self.api_key:
query['session_api_key'] = self.api_key
await self.sio.connect(
f'{self.base_url}',
headers=self.headers,
transports=['websocket'],
socketio_path='socket.io',
wait_timeout=10,
query=query,
)
async def send_message(self, message: str) -> None:
"""Send a message to the conversation.
Args:
message: The message to send.
"""
event = MessageAction(content=message)
event_dict = event_to_dict(event)
await self.sio.emit('oh_user_action', event_dict)
async def disconnect(self) -> None:
"""Disconnect from the WebSocket."""
await self.sio.disconnect()
async def list_conversations_cmd(client: TeamClient, args: argparse.Namespace) -> None:
"""List conversations command.
Args:
client: The TeamClient instance.
args: Command line arguments.
"""
conversations = await client.list_conversations(limit=args.limit)
if not conversations:
print('No conversations found.')
return
table = Table(title='Conversations')
table.add_column('ID', style='cyan')
table.add_column('Title', style='green')
table.add_column('Status', style='magenta')
table.add_column('Repository', style='blue')
table.add_column('Last Updated', style='yellow')
table.add_column('Created', style='yellow')
for convo in conversations:
# Format dates
created_at = datetime.fromisoformat(convo['created_at'].replace('Z', '+00:00'))
last_updated_at = datetime.fromisoformat(
convo['last_updated_at'].replace('Z', '+00:00')
)
created_str = created_at.strftime('%Y-%m-%d %H:%M:%S')
updated_str = last_updated_at.strftime('%Y-%m-%d %H:%M:%S')
# Add row to table
table.add_row(
convo['conversation_id'],
convo['title'],
convo['status'],
convo.get('selected_repository', ''),
updated_str,
created_str,
)
client.console.print(table)
async def create_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
"""Create a conversation command.
Args:
client: The TeamClient instance.
args: Command line arguments.
"""
initial_message = args.message
# If no message provided, prompt for one
if not initial_message:
print_formatted_text(HTML('<green>Enter your initial message:</green>'))
initial_message = input('> ')
try:
conversation_id = await client.create_conversation(
repository=args.repository,
git_provider=args.git_provider,
selected_branch=args.branch,
initial_user_msg=initial_message,
conversation_instructions=args.instructions,
)
print_formatted_text(
HTML(f'<green>Conversation created with ID: {conversation_id}</green>')
)
if args.join:
await join_conversation_cmd(
client, argparse.Namespace(conversation_id=conversation_id)
)
except Exception as e:
print_formatted_text(HTML(f'<red>Error creating conversation: {str(e)}</red>'))
async def join_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
"""Join a conversation command.
Args:
client: The TeamClient instance.
args: Command line arguments.
"""
conversation_id = args.conversation_id
try:
# Get conversation details
conversation = await client.get_conversation(conversation_id)
# Clear screen and show banner
clear()
display_banner(session_id=conversation_id)
# Show conversation title
title = conversation.get('title', 'Untitled Conversation')
display_welcome_message(f'Joined conversation: {title}')
# Connect to the WebSocket
await client.connect_to_conversation(conversation_id)
# Main conversation loop
try:
while True:
next_message = await read_prompt_input(
AgentState.AWAITING_USER_INPUT.value
)
if not next_message.strip():
continue
if next_message.lower() in ['exit', 'quit', '/exit', '/quit']:
break
await client.send_message(next_message)
except KeyboardInterrupt:
print('\nDisconnecting...')
finally:
await client.disconnect()
except Exception as e:
print_formatted_text(HTML(f'<red>Error joining conversation: {str(e)}</red>'))
def get_base_url() -> str:
"""Get the base URL for the OpenHands API.
Returns:
The base URL.
"""
# Check environment variables first
base_url = os.environ.get('OPENHANDS_API_URL')
if base_url:
return base_url
# Default to staging server
return 'https://staging.all-hands.dev'
def get_api_key() -> Optional[str]:
"""Get the API key for authentication.
Returns:
The API key, or None if not found.
"""
return os.environ.get('OPENHANDS_API_KEY')
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the team CLI.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(description='OpenHands Team CLI')
parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
# Server configuration
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
subparsers = parser.add_subparsers(dest='command', help='Command to run')
# List conversations command
list_parser = subparsers.add_parser(
'list',
help='List conversations',
description='List all available conversations',
)
list_parser.add_argument(
'-l',
'--limit',
type=int,
default=20,
help='Maximum number of conversations to list',
)
# Add help formatter
list_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
# Create conversation command
create_parser = subparsers.add_parser(
'create',
help='Create a new conversation',
description='Create a new conversation with optional repository and message',
)
create_parser.add_argument(
'-r', '--repository', help='Repository name (owner/repo)'
)
create_parser.add_argument(
'-g', '--git-provider', help='Git provider (github or gitlab)'
)
create_parser.add_argument('-b', '--branch', help='Branch name')
create_parser.add_argument('-m', '--message', help='Initial user message')
create_parser.add_argument('-i', '--instructions', help='Conversation instructions')
create_parser.add_argument(
'-j', '--join', action='store_true', help='Join the conversation after creation'
)
# Add help formatter
create_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
# Join conversation command
join_parser = subparsers.add_parser(
'join',
help='Join an existing conversation',
description='Join an existing conversation by ID',
)
join_parser.add_argument('conversation_id', help='Conversation ID')
# Add help formatter
join_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
return parser
async def main_async(args: argparse.Namespace) -> None:
"""Main async function for the team CLI.
Args:
args: Command line arguments.
"""
# Get base URL and API key
base_url = args.url or get_base_url()
api_key = args.api_key or get_api_key()
# Create client
client = TeamClient(base_url, api_key)
# Run command
if args.command == 'list':
await list_conversations_cmd(client, args)
elif args.command == 'create':
await create_conversation_cmd(client, args)
elif args.command == 'join':
await join_conversation_cmd(client, args)
else:
print('No command specified. Use --help for usage information.')
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the team CLI.
Args:
args: Command line arguments.
"""
parser = setup_parser()
# If no arguments provided, show help
if not args or len(args) == 0:
parser.print_help()
return
# Special case for subcommand help
if (
len(args) >= 2
and args[0] in ['list', 'create', 'join']
and args[1] in ['-h', '--help']
):
# Create a new parser just for this subcommand
if args[0] == 'list':
subparser = argparse.ArgumentParser(
description='List all available conversations',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparser.add_argument(
'-l',
'--limit',
type=int,
default=20,
help='Maximum number of conversations to list',
)
subparser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
subparser.add_argument(
'--api-key',
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
)
subparser.print_help()
return
elif args[0] == 'create':
subparser = argparse.ArgumentParser(
description='Create a new conversation with optional repository and message',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparser.add_argument(
'-r',
'--repository',
help='Repository name (owner/repo)',
)
subparser.add_argument(
'-g',
'--git-provider',
help='Git provider (github or gitlab)',
)
subparser.add_argument('-b', '--branch', help='Branch name')
subparser.add_argument('-m', '--message', help='Initial user message')
subparser.add_argument(
'-i',
'--instructions',
help='Conversation instructions',
)
subparser.add_argument(
'-j',
'--join',
action='store_true',
help='Join the conversation after creation',
)
subparser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
subparser.add_argument(
'--api-key',
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
)
subparser.print_help()
return
elif args[0] == 'join':
subparser = argparse.ArgumentParser(
description='Join an existing conversation by ID',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparser.add_argument('conversation_id', help='Conversation ID')
subparser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
subparser.add_argument(
'--api-key',
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
)
subparser.print_help()
return
try:
parsed_args = parser.parse_args(args)
# If no command specified, show help
if not parsed_args.command:
parser.print_help()
return
# Run the command
asyncio.run(main_async(parsed_args))
except KeyboardInterrupt:
print('\nOperation cancelled by user.')
except Exception as e:
print(f'Error: {str(e)}')
sys.exit(1)
if __name__ == '__main__':
main()
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
# Get the Python executable
PYTHON_EXE=$(which python)
# Run the team CLI
$PYTHON_EXE -m openhands.cli.team "$@"
-76
View File
@@ -1,76 +0,0 @@
"""Create conversation command for the OpenHands Team CLI."""
import argparse
import sys
from typing import Optional
from openhands.cli.team import TeamClient
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the create command.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(
description='Create a new conversation with optional repository and message',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument('-r', '--repository', help='Repository name (owner/repo)')
parser.add_argument('-g', '--git-provider', help='Git provider (github or gitlab)')
parser.add_argument('-b', '--branch', help='Branch name')
parser.add_argument('-m', '--message', help='Initial user message')
parser.add_argument('-i', '--instructions', help='Conversation instructions')
parser.add_argument(
'-j', '--join', action='store_true', help='Join the conversation after creation'
)
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
return parser
async def create_conversation(args: argparse.Namespace) -> None:
"""Create a conversation command.
Args:
args: Command line arguments.
"""
# Create client
client = TeamClient(args.url, args.api_key)
try:
# Create conversation
await client.create_conversation(
repository=args.repository,
git_provider=args.git_provider,
selected_branch=args.branch,
initial_user_msg=args.message,
conversation_instructions=args.instructions,
)
except Exception as e:
print(f'Error creating conversation: {e}')
sys.exit(1)
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the create command.
Args:
args: Command line arguments.
"""
parser = setup_parser()
parsed_args = parser.parse_args(args)
import asyncio
asyncio.run(create_conversation(parsed_args))
if __name__ == '__main__':
main()
-63
View File
@@ -1,63 +0,0 @@
"""Join conversation command for the OpenHands Team CLI."""
import argparse
import sys
from typing import Optional
from openhands.cli.team import TeamClient, join_conversation_cmd
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the join command.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(
description='Join an existing conversation by ID',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument('conversation_id', help='Conversation ID')
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
return parser
async def join_conversation(args: argparse.Namespace) -> None:
"""Join a conversation command.
Args:
args: Command line arguments.
"""
# Create client
client = TeamClient(args.url, args.api_key)
try:
# Join conversation
await join_conversation_cmd(client, args)
except Exception as e:
print(f'Error joining conversation: {e}')
sys.exit(1)
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the join command.
Args:
args: Command line arguments.
"""
parser = setup_parser()
parsed_args = parser.parse_args(args)
import asyncio
asyncio.run(join_conversation(parsed_args))
if __name__ == '__main__':
main()
-69
View File
@@ -1,69 +0,0 @@
"""List conversations command for the OpenHands Team CLI."""
import argparse
import sys
from typing import Optional
from openhands.cli.team import TeamClient
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the list command.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(
description='List all available conversations',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
'-l',
'--limit',
type=int,
default=20,
help='Maximum number of conversations to list',
)
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
return parser
async def list_conversations(args: argparse.Namespace) -> None:
"""List conversations command.
Args:
args: Command line arguments.
"""
# Create client
client = TeamClient(args.url, args.api_key)
try:
# List conversations
await client.list_conversations(limit=args.limit)
except Exception as e:
print(f'Error listing conversations: {e}')
sys.exit(1)
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the list command.
Args:
args: Command line arguments.
"""
parser = setup_parser()
parsed_args = parser.parse_args(args)
import asyncio
asyncio.run(list_conversations(parsed_args))
if __name__ == '__main__':
main()
+1 -14
View File
@@ -744,26 +744,13 @@ def get_parser() -> argparse.ArgumentParser:
type=bool,
default=False,
)
# Add team subcommand
subparsers = parser.add_subparsers(dest='command')
subparsers.add_parser(
'team', help='Use team mode to interact with the OpenHands API'
)
# We'll handle the team subcommands separately
return parser
def parse_arguments() -> argparse.Namespace:
"""Parse command line arguments."""
parser = get_parser()
# Check if 'team' command is present
if len(sys.argv) > 1 and sys.argv[1] == 'team':
# Only parse known arguments, ignoring any team-specific arguments
args, _ = parser.parse_known_args()
else:
# Parse all arguments normally
args = parser.parse_args()
args = parser.parse_args()
if args.version:
print(f'OpenHands version: {__version__}')
+3
View File
@@ -91,3 +91,6 @@ class ActionType(str, Enum):
CONDENSATION = 'condensation'
"""Condenses a list of events into a summary."""
USER_FEEDBACK = 'user_feedback'
"""User feedback on messages or the entire trajectory."""
+2
View File
@@ -10,6 +10,7 @@ from openhands.events.action.agent import (
from openhands.events.action.browse import BrowseInteractiveAction, BrowseURLAction
from openhands.events.action.commands import CmdRunAction, IPythonRunCellAction
from openhands.events.action.empty import NullAction
from openhands.events.action.feedback import UserFeedbackAction
from openhands.events.action.files import (
FileEditAction,
FileReadAction,
@@ -38,4 +39,5 @@ __all__ = [
'AgentThinkAction',
'RecallAction',
'MCPAction',
'UserFeedbackAction',
]
+32
View File
@@ -0,0 +1,32 @@
from dataclasses import dataclass
from typing import Literal, Optional
from openhands.core.schema import ActionType
from openhands.events.action.action import Action
@dataclass
class UserFeedbackAction(Action):
"""An action where the user provides feedback on a message or the entire trajectory.
Attributes:
feedback_type (str): The type of feedback, either "positive" or "negative".
target_type (str): The target of the feedback, either "message" or "trajectory".
target_id (Optional[int]): The ID of the target message, if target_type is "message".
rating (Optional[int]): A numeric rating from 1-5 for the feedback (used in SAAS mode).
reason (Optional[str]): A reason for the feedback (used in SAAS mode).
action (str): The action type, namely ActionType.USER_FEEDBACK.
"""
feedback_type: Literal["positive", "negative"]
target_type: Literal["message", "trajectory"]
target_id: Optional[int] = None
rating: Optional[int] = None
reason: Optional[str] = None
action: str = ActionType.USER_FEEDBACK
@property
def message(self) -> str:
if self.target_type == "message":
return f"User provided {self.feedback_type} feedback for message {self.target_id}"
return f"User provided {self.feedback_type} feedback for the trajectory"
+4
View File
@@ -3,6 +3,7 @@ from typing import Iterable
from urllib.parse import urlencode
import httpx # type: ignore
from fastapi import status
from openhands.events.event import Event
from openhands.events.event_filter import EventFilter
@@ -42,6 +43,9 @@ class NestedEventStore(EventStoreABC):
if self.session_api_key:
headers['X-Session-API-Key'] = self.session_api_key
response = httpx.get(url, headers=headers)
if response.status_code == status.HTTP_404_NOT_FOUND:
# Follow pattern of event store not throwing errors on not found
return
result_set = response.json()
for result in result_set['events']:
event = event_from_dict(result)
@@ -1,5 +1,8 @@
Please summarize your work.
Please send a final message summarizing your work.
If you answered a question, please re-state the answer to the question
If you made changes, please create a concise overview on whether the request has been addressed successfully or if there are were issues with the attempt.
If successful, make sure your changes are pushed to the remote branch.
If you simply answered a question, this final message should re-state the answer to the question.
If you made changes, please first double-check the git diff, think carefully about the user's request(s), and check:
1. whether the request has been completely addressed and all of the instructions have been followed faithfully (in checklist format if appropriate).
2. whether the changes are concise (if there are any extraneous changes not important to addressing the user's request they should be reverted).
If the request has been addressed and the changes are concise, then push your changes to the remote branch and send a final message summarizing the changes.
+66 -170
View File
@@ -1,13 +1,12 @@
import asyncio
import datetime
from contextlib import AsyncExitStack
from typing import Optional
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from fastmcp import Client
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from mcp import McpError
from mcp.types import CallToolResult
from pydantic import BaseModel, Field
from openhands.core.config.mcp_config import MCPSHTTPServerConfig, MCPSSEServerConfig
from openhands.core.logger import openhands_logger as logger
from openhands.mcp.tool import MCPClientTool
@@ -17,8 +16,7 @@ class MCPClient(BaseModel):
A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol.
"""
session: Optional[ClientSession] = None
exit_stack: AsyncExitStack = AsyncExitStack()
client: Optional[Client] = None
description: str = 'MCP client tools for server interaction'
tools: list[MCPClientTool] = Field(default_factory=list)
tool_map: dict[str, MCPClientTool] = Field(default_factory=dict)
@@ -26,189 +24,87 @@ class MCPClient(BaseModel):
class Config:
arbitrary_types_allowed = True
async def connect_sse(
self,
server_url: str,
api_key: str | None = None,
conversation_id: str | None = None,
timeout: float = 30.0,
) -> None:
"""Connect to an MCP server using SSE transport.
Args:
server_url: The URL of the SSE server to connect to.
timeout: Connection timeout in seconds. Default is 30 seconds.
"""
if not server_url:
raise ValueError('Server URL is required.')
if self.session:
await self.disconnect()
try:
# Use asyncio.wait_for to enforce the timeout
async def connect_with_timeout():
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
# Convert float timeout to datetime.timedelta for consistency
timeout_delta = datetime.timedelta(seconds=timeout)
streams_context = sse_client(
url=server_url,
headers=headers if headers else None,
timeout=timeout,
)
streams = await self.exit_stack.enter_async_context(streams_context)
# For SSE client, we only get read_stream and write_stream (2 values)
read_stream, write_stream = streams
self.session = await self.exit_stack.enter_async_context(
ClientSession(
read_stream, write_stream, read_timeout_seconds=timeout_delta
)
)
await self._initialize_and_list_tools()
# Apply timeout to the entire connection process
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
except asyncio.TimeoutError:
logger.error(
f'Connection to {server_url} timed out after {timeout} seconds'
)
await self.disconnect() # Clean up resources
raise # Re-raise the TimeoutError
except Exception as e:
logger.error(f'Error connecting to {server_url}: {str(e)}')
await self.disconnect() # Clean up resources
raise
async def _initialize_and_list_tools(self) -> None:
"""Initialize session and populate tool map."""
if not self.session:
if not self.client:
raise RuntimeError('Session not initialized.')
await self.session.initialize()
response = await self.session.list_tools()
async with self.client:
tools = await self.client.list_tools()
# Clear existing tools
self.tools = []
# Create proper tool objects for each server tool
for tool in response.tools:
for tool in tools:
server_tool = MCPClientTool(
name=tool.name,
description=tool.description,
inputSchema=tool.inputSchema,
session=self.session,
session=self.client,
)
self.tool_map[tool.name] = server_tool
self.tools.append(server_tool)
logger.info(
f'Connected to server with tools: {[tool.name for tool in response.tools]}'
)
logger.info(f'Connected to server with tools: {[tool.name for tool in tools]}')
async def call_tool(self, tool_name: str, args: dict):
async def connect_http(
self,
server: MCPSSEServerConfig | MCPSHTTPServerConfig,
conversation_id: str | None = None,
timeout: float = 30.0,
):
"""Connect to MCP server using SHTTP or SSE transport"""
server_url = server.url
api_key = server.api_key
if not server_url:
raise ValueError('Server URL is required.')
try:
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-ServerConversation-ID'] = conversation_id
# Instantiate custom transports due to custom headers
if isinstance(server, MCPSHTTPServerConfig):
transport = StreamableHttpTransport(
url=server_url,
headers=headers if headers else None,
)
else:
transport = SSETransport(
url=server_url,
headers=headers if headers else None,
)
self.client = Client(transport, timeout=timeout)
await self._initialize_and_list_tools()
except McpError as e:
logger.error(f'McpError connecting to {server_url}: {e}')
raise # Re-raise the error
except Exception as e:
logger.error(f'Error connecting to {server_url}: {e}')
raise
async def call_tool(self, tool_name: str, args: dict) -> CallToolResult:
"""Call a tool on the MCP server."""
if tool_name not in self.tool_map:
raise ValueError(f'Tool {tool_name} not found.')
# The MCPClientTool is primarily for metadata; use the session to call the actual tool.
if not self.session:
if not self.client:
raise RuntimeError('Client session is not available.')
return await self.session.call_tool(name=tool_name, arguments=args)
async def connect_shttp(
self,
server_url: str,
api_key: str | None = None,
conversation_id: str | None = None,
timeout: float = 30.0,
) -> None:
"""Connect to an MCP server using StreamableHTTP transport.
Args:
server_url: The URL of the StreamableHTTP server to connect to.
api_key: Optional API key for authentication.
conversation_id: Optional conversation ID for session tracking.
timeout: Connection timeout in seconds. Default is 30 seconds.
"""
if not server_url:
raise ValueError('Server URL is required.')
if self.session:
await self.disconnect()
try:
# Use asyncio.wait_for to enforce the timeout
async def connect_with_timeout():
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
# Convert float timeout to datetime.timedelta
timeout_delta = datetime.timedelta(seconds=timeout)
sse_read_timeout_delta = datetime.timedelta(
seconds=timeout * 10
) # 10x longer for read timeout
streams_context = streamablehttp_client(
url=server_url,
headers=headers if headers else None,
timeout=timeout_delta,
sse_read_timeout=sse_read_timeout_delta,
)
streams = await self.exit_stack.enter_async_context(streams_context)
# For StreamableHTTP client, we get read_stream, write_stream, and get_session_id (3 values)
read_stream, write_stream, _ = streams
self.session = await self.exit_stack.enter_async_context(
ClientSession(
read_stream, write_stream, read_timeout_seconds=timeout_delta
)
)
await self._initialize_and_list_tools()
# Apply timeout to the entire connection process
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
except asyncio.TimeoutError:
logger.error(
f'Connection to {server_url} timed out after {timeout} seconds'
)
await self.disconnect() # Clean up resources
raise # Re-raise the TimeoutError
except Exception as e:
logger.error(f'Error connecting to {server_url}: {str(e)}')
await self.disconnect() # Clean up resources
raise
async def disconnect(self) -> None:
"""Disconnect from the MCP server and clean up resources."""
if self.session:
try:
# Close the session first
if hasattr(self.session, 'close'):
await self.session.close()
# Then close the exit stack
await self.exit_stack.aclose()
except Exception as e:
logger.error(f'Error during disconnect: {str(e)}')
finally:
self.session = None
self.tools = []
logger.info('Disconnected from MCP server')
async with self.client:
return await self.client.call_tool_mcp(name=tool_name, arguments=args)
+4 -27
View File
@@ -72,38 +72,22 @@ async def create_mcp_clients(
mcp_clients = []
for server in servers:
is_sse = isinstance(server, MCPSSEServerConfig)
connection_type = 'SSE' if is_sse else 'SHTTP'
is_shttp = isinstance(server, MCPSHTTPServerConfig)
connection_type = 'SHTTP' if is_shttp else 'SSE'
logger.info(
f'Initializing MCP agent for {server} with {connection_type} connection...'
)
client = MCPClient()
try:
if is_sse:
await client.connect_sse(
server.url,
api_key=server.api_key,
conversation_id=conversation_id,
)
else:
await client.connect_shttp(
server.url,
api_key=server.api_key,
conversation_id=conversation_id,
)
await client.connect_http(server, conversation_id=conversation_id)
# Only add the client to the list after a successful connection
mcp_clients.append(client)
except Exception as e:
logger.error(f'Failed to connect to {server}: {str(e)}', exc_info=True)
try:
await client.disconnect()
except Exception as disconnect_error:
logger.error(
f'Error during disconnect after failed connection: {str(disconnect_error)}'
)
return mcp_clients
@@ -143,13 +127,6 @@ async def fetch_mcp_tools_from_config(
# Convert tools to the format expected by the agent
mcp_tools = convert_mcp_clients_to_tools(mcp_clients)
# Always disconnect clients to clean up resources
for mcp_client in mcp_clients:
try:
await mcp_client.disconnect()
except Exception as disconnect_error:
logger.error(f'Error disconnecting MCP client: {str(disconnect_error)}')
except Exception as e:
logger.error(f'Error fetching MCP tools: {str(e)}')
return []
+74 -8
View File
@@ -1,4 +1,5 @@
import io
import re
from pathlib import Path
from typing import Union
@@ -9,7 +10,7 @@ from openhands.core.exceptions import (
MicroagentValidationError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.microagent.types import MicroagentMetadata, MicroagentType
from openhands.microagent.types import InputMetadata, MicroagentMetadata, MicroagentType
class BaseMicroagent(BaseModel):
@@ -91,13 +92,24 @@ class BaseMicroagent(BaseModel):
subclass_map = {
MicroagentType.KNOWLEDGE: KnowledgeMicroagent,
MicroagentType.REPO_KNOWLEDGE: RepoMicroagent,
MicroagentType.TASK: TaskMicroagent,
}
# Infer the agent type:
# 1. If triggers exist -> KNOWLEDGE (optional)
# 2. Else (no triggers) -> REPO (always active)
# 1. If inputs exist -> TASK
# 2. If triggers exist -> KNOWLEDGE
# 3. Else (no triggers) -> REPO (always active)
inferred_type: MicroagentType
if metadata.triggers:
if metadata.inputs:
inferred_type = MicroagentType.TASK
# Add a trigger for the agent name if not already present
trigger = f'/{metadata.name}'
if not metadata.triggers or trigger not in metadata.triggers:
if not metadata.triggers:
metadata.triggers = [trigger]
else:
metadata.triggers.append(trigger)
elif metadata.triggers:
inferred_type = MicroagentType.KNOWLEDGE
else:
# No triggers, default to REPO
@@ -122,7 +134,9 @@ class BaseMicroagent(BaseModel):
class KnowledgeMicroagent(BaseMicroagent):
"""Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations. They help with:
"""Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations.
They help with:
- Language best practices
- Framework guidelines
- Common patterns
@@ -131,8 +145,8 @@ class KnowledgeMicroagent(BaseMicroagent):
def __init__(self, **data):
super().__init__(**data)
if self.type != MicroagentType.KNOWLEDGE:
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE')
if self.type not in [MicroagentType.KNOWLEDGE, MicroagentType.TASK]:
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE or TASK')
def match_trigger(self, message: str) -> str | None:
"""Match a trigger in the message.
@@ -171,6 +185,57 @@ class RepoMicroagent(BaseMicroagent):
)
class TaskMicroagent(KnowledgeMicroagent):
"""TaskMicroagent is a special type of KnowledgeMicroagent that requires user input.
These microagents are triggered by a special format: "/{agent_name}"
and will prompt the user for any required inputs before proceeding.
"""
def __init__(self, **data):
super().__init__(**data)
if self.type != MicroagentType.TASK:
raise ValueError(
f'TaskMicroagent initialized with incorrect type: {self.type}'
)
# Append a prompt to ask for missing variables
self._append_missing_variables_prompt()
def _append_missing_variables_prompt(self) -> None:
"""Append a prompt to ask for missing variables."""
# Check if the content contains any variables or has inputs defined
if not self.requires_user_input() and not self.metadata.inputs:
return
prompt = "\n\nIf the user didn't provide any of these variables, ask the user to provide them first before the agent can proceed with the task."
self.content += prompt
def extract_variables(self, content: str) -> list[str]:
"""Extract variables from the content.
Variables are in the format ${variable_name}.
"""
pattern = r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}'
matches = re.findall(pattern, content)
return matches
def requires_user_input(self) -> bool:
"""Check if this microagent requires user input.
Returns True if the content contains variables in the format ${variable_name}.
"""
# Check if the content contains any variables
variables = self.extract_variables(self.content)
logger.debug(f'This microagent requires user input: {variables}')
return len(variables) > 0
@property
def inputs(self) -> list[InputMetadata]:
"""Get the inputs for this microagent."""
return self.metadata.inputs
def load_microagents_from_dir(
microagent_dir: Union[str, Path],
) -> tuple[dict[str, RepoMicroagent], dict[str, KnowledgeMicroagent]]:
@@ -182,7 +247,7 @@ def load_microagents_from_dir(
microagent_dir: Path to the microagents directory (e.g. .openhands/microagents)
Returns:
Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries
Tuple of (repo_agents, knowledge_agents) dictionaries
"""
if isinstance(microagent_dir, str):
microagent_dir = Path(microagent_dir)
@@ -202,6 +267,7 @@ def load_microagents_from_dir(
if isinstance(agent, RepoMicroagent):
repo_agents[agent.name] = agent
elif isinstance(agent, KnowledgeMicroagent):
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
knowledge_agents[agent.name] = agent
except MicroagentValidationError as e:
# For validation errors, include the original exception
+9
View File
@@ -12,6 +12,14 @@ class MicroagentType(str, Enum):
KNOWLEDGE = 'knowledge' # Optional microagent, triggered by keywords
REPO_KNOWLEDGE = 'repo' # Always active microagent
TASK = 'task' # Special type for task microagents that require user input
class InputMetadata(BaseModel):
"""Metadata for task microagent inputs."""
name: str
description: str
class MicroagentMetadata(BaseModel):
@@ -22,6 +30,7 @@ class MicroagentMetadata(BaseModel):
version: str = Field(default='1.0.0')
agent: str = Field(default='CodeActAgent')
triggers: list[str] = [] # optional, only exists for knowledge microagents
inputs: list[InputMetadata] = [] # optional, only exists for task microagents
mcp_tools: MCPConfig | None = (
None # optional, for microagents that provide additional MCP tools
)
+50 -102
View File
@@ -9,7 +9,6 @@ import argparse
import asyncio
import base64
import json
import logging
import mimetypes
import os
import shutil
@@ -26,8 +25,6 @@ from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import APIKeyHeader
from mcpm import MCPRouter, RouterConfig
from mcpm.router.router import logger as mcp_router_logger
from openhands_aci.editor.editor import OHEditor
from openhands_aci.editor.exceptions import ToolError
from openhands_aci.editor.results import ToolResult
@@ -37,6 +34,7 @@ from starlette.background import BackgroundTask
from starlette.exceptions import HTTPException as StarletteHTTPException
from uvicorn import run
from openhands.core.config.mcp_config import MCPStdioServerConfig
from openhands.core.exceptions import BrowserUnavailableException
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
@@ -63,20 +61,18 @@ from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.runtime.browser import browse
from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.runtime.file_viewer_server import start_file_viewer_server
# Import our custom MCP Proxy Manager
from openhands.runtime.mcp.proxy import MCPProxyManager
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.log_capture import capture_logs
from openhands.runtime.utils.memory_monitor import MemoryMonitor
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
# Set MCP router logger to the same level as the main logger
mcp_router_logger.setLevel(logger.getEffectiveLevel())
if sys.platform == 'win32':
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
@@ -471,7 +467,7 @@ class ActionExecutor:
filepath = self._resolve_path(action.path, working_dir)
try:
if filepath.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
with open(filepath, 'rb') as file: # noqa: ASYNC101
with open(filepath, 'rb') as file:
image_data = file.read()
encoded_image = base64.b64encode(image_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
@@ -481,13 +477,13 @@ class ActionExecutor:
return FileReadObservation(path=filepath, content=encoded_image)
elif filepath.lower().endswith('.pdf'):
with open(filepath, 'rb') as file: # noqa: ASYNC101
with open(filepath, 'rb') as file:
pdf_data = file.read()
encoded_pdf = base64.b64encode(pdf_data).decode('utf-8')
encoded_pdf = f'data:application/pdf;base64,{encoded_pdf}'
return FileReadObservation(path=filepath, content=encoded_pdf)
elif filepath.lower().endswith(('.mp4', '.webm', '.ogg')):
with open(filepath, 'rb') as file: # noqa: ASYNC101
with open(filepath, 'rb') as file:
video_data = file.read()
encoded_video = base64.b64encode(video_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
@@ -497,7 +493,7 @@ class ActionExecutor:
return FileReadObservation(path=filepath, content=encoded_video)
with open(filepath, 'r', encoding='utf-8') as file: # noqa: ASYNC101
with open(filepath, 'r', encoding='utf-8') as file:
lines = read_lines(file.readlines(), action.start, action.end)
except FileNotFoundError:
return ErrorObservation(
@@ -530,7 +526,7 @@ class ActionExecutor:
mode = 'w' if not file_exists else 'r+'
try:
with open(filepath, mode, encoding='utf-8') as file: # noqa: ASYNC101
with open(filepath, mode, encoding='utf-8') as file:
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(insert, all_lines, action.start, action.end)
@@ -654,14 +650,11 @@ if __name__ == '__main__':
plugins_to_load.append(ALL_PLUGINS[plugin]()) # type: ignore
client: ActionExecutor | None = None
mcp_router: MCPRouter | None = None
MCP_ROUTER_PROFILE_PATH = os.path.join(
os.path.dirname(__file__), 'mcp', 'config.json'
)
mcp_proxy_manager: MCPProxyManager | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global client, mcp_router
global client, mcp_proxy_manager
logger.info('Initializing ActionExecutor...')
client = ActionExecutor(
plugins_to_load,
@@ -676,63 +669,36 @@ if __name__ == '__main__':
# Check if we're on Windows
is_windows = sys.platform == 'win32'
# Initialize and mount MCP Router (skip on Windows)
# Initialize and mount MCP Proxy Manager (skip on Windows)
if is_windows:
logger.info('Skipping MCP Router initialization on Windows')
mcp_router = None
logger.info('Skipping MCP Proxy initialization on Windows')
mcp_proxy_manager = None
else:
logger.info('Initializing MCP Router...')
mcp_router = MCPRouter(
profile_path=MCP_ROUTER_PROFILE_PATH,
router_config=RouterConfig(
api_key=SESSION_API_KEY,
auth_enabled=bool(SESSION_API_KEY),
),
logger.info('Initializing MCP Proxy Manager...')
# Create a MCP Proxy Manager
mcp_proxy_manager = MCPProxyManager(
auth_enabled=bool(SESSION_API_KEY),
api_key=SESSION_API_KEY,
logger_level=logger.getEffectiveLevel(),
)
mcp_proxy_manager.initialize()
# Mount the proxy to the app
allowed_origins = ['*']
sse_app = await mcp_router.get_sse_server_app(
allow_origins=allowed_origins, include_lifespan=False
)
# Only mount SSE app if MCP Router is initialized (not on Windows)
if mcp_router is not None:
# Check for route conflicts before mounting
main_app_routes = {route.path for route in app.routes}
sse_app_routes = {route.path for route in sse_app.routes}
conflicting_routes = main_app_routes.intersection(sse_app_routes)
if conflicting_routes:
logger.error(f'Route conflicts detected: {conflicting_routes}')
raise RuntimeError(
f'Cannot mount SSE app - conflicting routes found: {conflicting_routes}'
)
app.mount('/', sse_app)
logger.info(
f'Mounted MCP Router SSE app at root path with allowed origins: {allowed_origins}'
)
# Additional debug logging
if logger.isEnabledFor(logging.DEBUG):
logger.debug('Main app routes:')
for route in main_app_routes:
logger.debug(f' {route}')
logger.debug('MCP SSE server app routes:')
for route in sse_app_routes:
logger.debug(f' {route}')
try:
await mcp_proxy_manager.mount_to_app(app, allowed_origins)
except Exception as e:
logger.error(f'Error mounting MCP Proxy: {e}', exc_info=True)
raise RuntimeError(f'Cannot mount MCP Proxy: {e}')
yield
# Clean up & release the resources
logger.info('Shutting down MCP Router...')
if mcp_router:
try:
await mcp_router.shutdown()
logger.info('MCP Router shutdown successfully.')
except Exception as e:
logger.error(f'Error shutting down MCP Router: {e}', exc_info=True)
logger.info('Shutting down MCP Proxy Manager...')
if mcp_proxy_manager:
del mcp_proxy_manager
mcp_proxy_manager = None
else:
logger.info('MCP Router instance not found for shutdown.')
logger.info('MCP Proxy Manager instance not found for shutdown.')
logger.info('Closing ActionExecutor...')
if client:
@@ -824,6 +790,9 @@ if __name__ == '__main__':
# Check if we're on Windows
is_windows = sys.platform == 'win32'
# Access the global mcp_proxy_manager variable
global mcp_proxy_manager
if is_windows:
# On Windows, just return a success response without doing anything
logger.info(
@@ -838,17 +807,10 @@ if __name__ == '__main__':
)
# Non-Windows implementation
assert mcp_router is not None
assert os.path.exists(MCP_ROUTER_PROFILE_PATH)
# Use synchronous file operations outside of async function
def read_profile():
with open(MCP_ROUTER_PROFILE_PATH, 'r') as f:
return json.load(f)
current_profile = read_profile()
assert 'default' in current_profile
assert isinstance(current_profile['default'], list)
if mcp_proxy_manager is None:
raise HTTPException(
status_code=500, detail='MCP Proxy Manager is not initialized'
)
# Get the request body
mcp_tools_to_sync = await request.json()
@@ -856,31 +818,17 @@ if __name__ == '__main__':
raise HTTPException(
status_code=400, detail='Request must be a list of MCP tools to sync'
)
logger.info(
f'Updating MCP server to: {json.dumps(mcp_tools_to_sync, indent=2)}.\nPrevious profile: {json.dumps(current_profile, indent=2)}'
f'Updating MCP server with tools: {json.dumps(mcp_tools_to_sync, indent=2)}'
)
current_profile['default'] = mcp_tools_to_sync
# Use synchronous file operations outside of async function
def write_profile(profile):
with open(MCP_ROUTER_PROFILE_PATH, 'w') as f:
json.dump(profile, f)
write_profile(current_profile)
# Manually reload the profile and update the servers
mcp_router.profile_manager.reload()
servers_wait_for_update = mcp_router.get_unique_servers()
async with capture_logs('mcpm.router.router') as log_capture:
await mcp_router.update_servers(servers_wait_for_update)
router_error_log = log_capture.getvalue()
logger.info(
f'MCP router updated successfully with unique servers: {servers_wait_for_update}'
)
if router_error_log:
logger.warning(f'Some MCP servers failed to be added: {router_error_log}')
mcp_tools_to_sync = [MCPStdioServerConfig(**tool) for tool in mcp_tools_to_sync]
try:
await mcp_proxy_manager.update_and_remount(app, mcp_tools_to_sync, ['*'])
logger.info('MCP Proxy Manager updated and remounted successfully')
router_error_log = ''
except Exception as e:
logger.error(f'Error updating MCP Proxy Manager: {e}', exc_info=True)
router_error_log = str(e)
return JSONResponse(
status_code=200,
@@ -915,7 +863,7 @@ if __name__ == '__main__':
)
zip_path = os.path.join(full_dest_path, file.filename)
with open(zip_path, 'wb') as buffer: # noqa: ASYNC101
with open(zip_path, 'wb') as buffer:
shutil.copyfileobj(file.file, buffer)
# Extract the zip file
@@ -928,7 +876,7 @@ if __name__ == '__main__':
else:
# For single file uploads
file_path = os.path.join(full_dest_path, file.filename)
with open(file_path, 'wb') as buffer: # noqa: ASYNC101
with open(file_path, 'wb') as buffer:
shutil.copyfileobj(file.file, buffer)
logger.debug(f'Uploaded file {file.filename} to {destination}')
@@ -435,7 +435,7 @@ class ActionExecutionClient(Runtime):
# We should always include the runtime as an MCP server whenever there's > 0 stdio servers
updated_mcp_config.sse_servers.append(
MCPSSEServerConfig(
url=self.action_execution_server_url.rstrip('/') + '/sse',
url=self.action_execution_server_url.rstrip('/') + '/mcp/sse',
api_key=self.session_api_key,
)
)
@@ -471,11 +471,6 @@ class ActionExecutionClient(Runtime):
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
result = await call_tool_mcp_handler(mcp_clients, action)
# Reset client state (no active connections to worry about)
for client in mcp_clients:
await client.disconnect()
return result
def close(self) -> None:
+4 -1
View File
@@ -1,3 +1,6 @@
{
"default": []
"mcpServers": {
"default": {}
},
"tools": []
}
+71
View File
@@ -0,0 +1,71 @@
# MCP Proxy Manager
This module provides a manager class for handling FastMCP proxy instances in OpenHands, including initialization, configuration, and mounting to FastAPI applications.
## Overview
The `MCPProxyManager` class encapsulates all the functionality related to creating, configuring, and managing FastMCP proxy instances. It simplifies the process of:
1. Initializing a FastMCP proxy
2. Configuring the proxy with tools
3. Mounting the proxy to a FastAPI application
4. Updating the proxy configuration
5. Shutting down the proxy
## Usage
### Basic Usage
```python
from openhands.runtime.mcp.proxy import MCPProxyManager
from fastapi import FastAPI
# Create a FastAPI app
app = FastAPI()
# Create a proxy manager
proxy_manager = MCPProxyManager(
name="MyProxyServer",
auth_enabled=True,
api_key="my-api-key"
)
# Initialize the proxy
proxy_manager.initialize()
# Mount the proxy to the app
await proxy_manager.mount_to_app(app, allow_origins=["*"])
# Update the tools configuration
tools = [
{
"name": "my_tool",
"description": "My tool description",
"parameters": {...}
}
]
proxy_manager.update_tools(tools)
# Update and remount the proxy
await proxy_manager.update_and_remount(app, tools, allow_origins=["*"])
# Shutdown the proxy
await proxy_manager.shutdown()
```
### In-Memory Configuration
The `MCPProxyManager` maintains the configuration in-memory, eliminating the need for file-based configuration. This makes it easier to update the configuration and reduces the complexity of the code.
## Benefits
1. **Simplified API**: The `MCPProxyManager` provides a simple and intuitive API for managing FastMCP proxies.
2. **In-Memory Configuration**: Configuration is maintained in-memory, eliminating the need for file I/O operations.
3. **Improved Error Handling**: The manager provides better error handling and logging for proxy operations.
4. **Cleaner Code**: By encapsulating proxy-related functionality in a dedicated class, the code is more maintainable and easier to understand.
## Implementation Details
The `MCPProxyManager` uses the `FastMCP.as_proxy()` method to create a proxy server. It manages the lifecycle of the proxy, including initialization, configuration updates, and shutdown.
When updating the tools configuration, the manager creates a new proxy with the updated configuration and remounts it to the FastAPI application, ensuring that the proxy is always up-to-date with the latest configuration.
+7
View File
@@ -0,0 +1,7 @@
"""
MCP Proxy module for OpenHands.
"""
from openhands.runtime.mcp.proxy.manager import MCPProxyManager
__all__ = ['MCPProxyManager']
+138
View File
@@ -0,0 +1,138 @@
"""
MCP Proxy Manager for OpenHands.
This module provides a manager class for handling FastMCP proxy instances,
including initialization, configuration, and mounting to FastAPI applications.
"""
import logging
from typing import Any, Optional
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.utilities.logging import get_logger as fastmcp_get_logger
from openhands.core.config.mcp_config import MCPStdioServerConfig
logger = logging.getLogger(__name__)
fastmcp_logger = fastmcp_get_logger('fastmcp')
class MCPProxyManager:
"""
Manager for FastMCP proxy instances.
This class encapsulates all the functionality related to creating, configuring,
and managing FastMCP proxy instances, including mounting them to FastAPI applications.
"""
def __init__(
self,
auth_enabled: bool = False,
api_key: Optional[str] = None,
logger_level: Optional[int] = None,
):
"""
Initialize the MCP Proxy Manager.
Args:
name: Name of the proxy server
auth_enabled: Whether authentication is enabled
api_key: API key for authentication (required if auth_enabled is True)
logger_level: Logging level for the FastMCP logger
"""
self.auth_enabled = auth_enabled
self.api_key = api_key
self.proxy: Optional[FastMCP] = None
# Initialize with a valid configuration format for FastMCP
self.config: dict[str, Any] = {
'mcpServers': {},
}
# Configure FastMCP logger
if logger_level is not None:
fastmcp_logger.setLevel(logger_level)
def initialize(self) -> None:
"""
Initialize the FastMCP proxy with the current configuration.
"""
if len(self.config['mcpServers']) == 0:
logger.info(
f"No MCP servers configured for FastMCP Proxy, skipping initialization."
)
return None
# Create a new proxy with the current configuration
self.proxy = FastMCP.as_proxy(
self.config,
auth_enabled=self.auth_enabled,
api_key=self.api_key,
)
logger.info(f"FastMCP Proxy initialized successfully")
async def mount_to_app(
self, app: FastAPI, allow_origins: Optional[list[str]] = None
) -> None:
"""
Mount the SSE server app to a FastAPI application.
Args:
app: FastAPI application to mount to
allow_origins: List of allowed origins for CORS
"""
if len(self.config['mcpServers']) == 0:
logger.info(
f"No MCP servers configured for FastMCP Proxy, skipping mount."
)
return
if not self.proxy:
raise ValueError('FastMCP Proxy is not initialized')
# Get the SSE app
# mcp_app = self.proxy.http_app(path='/shttp')
mcp_app = self.proxy.http_app(path='/sse', transport='sse')
app.mount('/mcp', mcp_app)
# Remove any existing mounts at root path
if '/mcp' in app.routes:
app.routes.remove('/mcp')
app.mount('/', mcp_app)
logger.info(f"Mounted FastMCP Proxy app at /mcp")
async def update_and_remount(
self,
app: FastAPI,
stdio_servers: list[MCPStdioServerConfig],
allow_origins: Optional[list[str]] = None,
) -> None:
"""
Update the tools configuration and remount the proxy to the app.
This is a convenience method that combines updating the tools,
shutting down the existing proxy, initializing a new one, and
mounting it to the app.
Args:
app: FastAPI application to mount to
tools: List of tool configurations
allow_origins: List of allowed origins for CORS
"""
tools = {
t.name: t.model_dump()
for t in stdio_servers
}
self.config['mcpServers'] = tools
del self.proxy
self.proxy = None
# Initialize a new proxy
self.initialize()
# Mount the new proxy to the app
await self.mount_to_app(app, allow_origins)
+3 -6
View File
@@ -17,6 +17,7 @@ from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
@@ -379,9 +380,7 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
command_output = self._get_command_output(
command,
@@ -414,9 +413,7 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command timed out after {timeout} seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
command_output = self._get_command_output(
command,
@@ -0,0 +1,7 @@
# Common timeout message that can be used across different timeout scenarios
TIMEOUT_MESSAGE_TEMPLATE = (
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'send keys to interrupt/kill the command, '
'or use the timeout parameter in execute_bash for future commands.'
)
+3 -6
View File
@@ -20,6 +20,7 @@ from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
pythonnet.load('coreclr')
@@ -559,9 +560,7 @@ class WindowsPowershellSession:
else:
metadata.suffix = (
f'\n[The command timed out after {timeout_seconds} seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
return CmdOutputObservation(
@@ -1331,9 +1330,7 @@ class WindowsPowershellSession:
# Align suffix with bash.py timeout message
suffix = (
f'\n[The command timed out after {timeout_seconds} seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
elif shutdown_requested:
# Align suffix with bash.py equivalent (though bash.py might not have specific shutdown message)
@@ -295,6 +295,14 @@ class DockerNestedConversationManager(ConversationManager):
) as client:
response = await client.post(f'{nested_url}/api/conversations/{sid}/stop')
response.raise_for_status()
# Check up to 3 times that client has closed
for _ in range(3):
response = await client.get(f'{nested_url}/api/conversations/{sid}')
if response.status_code == status.HTTP_200_OK and response.json().get('status') == "STOPPED":
break
await asyncio.sleep(1)
except Exception:
logger.exception("error_stopping_container")
container.stop()
+6 -2
View File
@@ -49,7 +49,7 @@ async def get_convo_link(service: GitService, conversation_id: str, body: str) -
async def save_pr_metadata(
user_id: str, conversation_id: str, tool_result: str
user_id: str | None, conversation_id: str, tool_result: str
) -> None:
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
conversation: ConversationMetadata = await conversation_store.get_metadata(
@@ -70,7 +70,11 @@ async def save_pr_metadata(
pr_number = int(match_merge_request.group(1))
if pr_number:
logger.info(f'Saving PR number: {pr_number} for convo {conversation_id}')
conversation.pr_number.append(pr_number)
else:
logger.warning(f'Failed to extract PR number for convo {conversation_id}')
await conversation_store.save_metadata(conversation)
@@ -124,7 +128,7 @@ async def create_pr(
body=body,
)
if conversation_id and user_id:
if conversation_id:
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
+2 -2
View File
@@ -15,6 +15,7 @@ from openhands.server.shared import config
from openhands.server.user_auth import (
get_provider_tokens,
get_secrets_store,
get_user_settings,
get_user_settings_store,
)
from openhands.storage.data_models.settings import Settings
@@ -35,10 +36,9 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
async def load_settings(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
settings_store: SettingsStore = Depends(get_user_settings_store),
settings: Settings = Depends(get_user_settings),
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> GETSettingsModel | JSONResponse:
settings = await settings_store.load()
try:
if not settings:
return JSONResponse(
@@ -25,6 +25,10 @@ class DefaultUserAuth(UserAuth):
"""The default implementation does not support multi tenancy, so user_id is always None"""
return None
async def get_user_email(self) -> str | None:
"""The default implementation does not support multi tenancy, so email is always None"""
return None
async def get_access_token(self) -> SecretStr | None:
"""The default implementation does not support multi tenancy, so access_token is always None"""
return None
+4
View File
@@ -38,6 +38,10 @@ class UserAuth(ABC):
async def get_user_id(self) -> str | None:
"""Get the unique identifier for the current user"""
@abstractmethod
async def get_user_email(self) -> str | None:
"""Get the email for the current user"""
@abstractmethod
async def get_access_token(self) -> SecretStr | None:
"""Get the access token for the current user"""
@@ -40,6 +40,8 @@ class Settings(BaseModel):
sandbox_runtime_container_image: str | None = None
mcp_config: MCPConfig | None = None
search_api_key: SecretStr | None = None
email: str | None = None
email_verified: bool | None = None
model_config = {
'validate_assignment': True,
Generated
+5 -151
View File
@@ -2116,52 +2116,6 @@ files = [
{file = "docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e"},
]
[[package]]
name = "duckdb"
version = "1.3.0"
description = "DuckDB in-process database"
optional = false
python-versions = ">=3.7.0"
groups = ["main"]
files = [
{file = "duckdb-1.3.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:fc65c1e97aa010359c43c0342ea423e6efa3cb8c8e3f133b0765451ce674e3db"},
{file = "duckdb-1.3.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:8fc91b629646679e33806342510335ccbbeaf2b823186f0ae829fd48e7a63c66"},
{file = "duckdb-1.3.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:1a69b970553fd015c557238d427ef00be3c8ed58c3bc3641aef987e33f8bf614"},
{file = "duckdb-1.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1003e84c07b84680cee6d06e4795b6e861892474704f7972058594a52c7473cf"},
{file = "duckdb-1.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:992239b54ca6f015ad0ed0d80f3492c065313c4641df0a226183b8860cb7f5b0"},
{file = "duckdb-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ba1c5af59e8147216149b814b1970b8f7e3c240494a9688171390db3c504b29"},
{file = "duckdb-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:57b794ca28e22b23bd170506cb1d4704a3608e67f0fe33273db9777b69bdf26a"},
{file = "duckdb-1.3.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:60a58b85929754abb21db1e739d2f53eaef63e6015e62ba58eae3425030e7935"},
{file = "duckdb-1.3.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:1d46b5a20f078b1b2284243e02a1fde7e12cbb8d205fce62e4700bcfe6a09881"},
{file = "duckdb-1.3.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0044e5ffb2d46308099640a92f99980a44e12bb68642aa9e6b08acbf300d64a1"},
{file = "duckdb-1.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cb813de2ca2f5e7c77392a67bdcaa174bfd69ebbfdfc983024af270c77a0447"},
{file = "duckdb-1.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a0c993eb6df2b30b189ad747f3aea1b0b87b78ab7f80c6e7c57117b6e8dbfb0"},
{file = "duckdb-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6728e209570d36ece66dd7249e5d6055326321137cd807f26300733283930cd4"},
{file = "duckdb-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7e652b7c8dbdb91a94fd7d543d3e115d24a25aa0791a373a852e20cb7bb21154"},
{file = "duckdb-1.3.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f24038fe9b83dcbaeafb1ed76ec3b3f38943c1c8d27ab464ad384db8a6658b61"},
{file = "duckdb-1.3.0-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:956c85842841bef68f4a5388c6b225b933151a7c06d568390fc895fc44607913"},
{file = "duckdb-1.3.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:efe883d822ed56fcfbb6a7b397c13f6a0d2eaeb3bc4ef4510f84fadb3dfe416d"},
{file = "duckdb-1.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3872a3a1b80ffba5264ea236a3754d0c41d3c7b01bdf8cdcb1c180fc1b8dc8e2"},
{file = "duckdb-1.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30bf45ad78a5a997f378863e036e917b481d18d685e5c977cd0a3faf2e31fbaf"},
{file = "duckdb-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85cbd8e1d65df8a0780023baf5045d3033fabd154799bc9ea6d9ab5728f41eb3"},
{file = "duckdb-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8754c40dac0f26d9fb0363bbb5df02f7a61ce6a6728d5efc02c3bc925d7c89c3"},
{file = "duckdb-1.3.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:176b9818d940c52ac7f31c64a98cf172d7c19d2a006017c9c4e9c06c246e36bf"},
{file = "duckdb-1.3.0-cp313-cp313-macosx_12_0_universal2.whl", hash = "sha256:03981f7e8793f07a4a9a2ba387640e71d0a99ebcaf8693ab09f96d59e628b713"},
{file = "duckdb-1.3.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:a177d55a38a62fdf79b59a0eaa32531a1dbb443265f6d67f64992cc1e82b755c"},
{file = "duckdb-1.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1c30e3749823147d5578bc3f01f35d1a0433a1c768908d946056ec8d6e1757e"},
{file = "duckdb-1.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5855f3a564baf22eeeab70c120b51f5a11914f1f1634f03382daeb6b1dea4c62"},
{file = "duckdb-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1fac15a48056f7c2739cf8800873063ba2f691e91a9b2fc167658a401ca76a"},
{file = "duckdb-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:fbdfc1c0b83b90f780ae74038187ee696bb56ab727a289752372d7ec42dda65b"},
{file = "duckdb-1.3.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:5f6b5d725546ad30abc125a6813734b493fea694bc3123e991c480744573c2f1"},
{file = "duckdb-1.3.0-cp39-cp39-macosx_12_0_universal2.whl", hash = "sha256:fcbcc9b956b06cf5ee94629438ecab88de89b08b5620fcda93665c222ab18cd4"},
{file = "duckdb-1.3.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:2d32f2d44105e1705d8a0fb6d6d246fd69aff82c80ad23293266244b66b69012"},
{file = "duckdb-1.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aa7a5c0dcb780850e6da1227fb1d552af8e1a5091e02667ab6ace61ab49ce6c"},
{file = "duckdb-1.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cb254fd5405f3edbd7d962ba39c72e4ab90b37cb4d0e34846089796c8078419"},
{file = "duckdb-1.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7d337b58c59fd2cd9faae531b05d940f8d92bdc2e14cb6e9a5a37675ad2742d"},
{file = "duckdb-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3cea3a345755c7dbcb58403dbab8befd499c82f0d27f893a4c1d4b8cf56ec54"},
{file = "duckdb-1.3.0.tar.gz", hash = "sha256:09aaa4b1dca24f4d1f231e7ae66b6413e317b7e04e2753541d42df6c8113fac7"},
]
[[package]]
name = "dulwich"
version = "0.22.8"
@@ -2969,8 +2923,8 @@ files = [
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
proto-plus = [
{version = ">=1.22.3,<2.0.0dev"},
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
@@ -2992,8 +2946,8 @@ googleapis-common-protos = ">=1.56.2,<2.0.0"
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
requests = ">=2.18.0,<3.0.0"
@@ -3211,8 +3165,8 @@ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
proto-plus = [
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
@@ -5398,30 +5352,6 @@ cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"]
rich = ["rich (>=13.9.4)"]
ws = ["websockets (>=15.0.1)"]
[[package]]
name = "mcpm"
version = "1.12.0"
description = "MCPM - Model Context Protocol Manager"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "mcpm-1.12.0-py3-none-any.whl", hash = "sha256:ed3a87300420bcdb9cd12ef290179fda5bd51eb2f4cd3e793084d83eed91b249"},
{file = "mcpm-1.12.0.tar.gz", hash = "sha256:e9d2b852b90d7fd62dede584f035dd6b2b3d068d233e96b82aead835f81a911a"},
]
[package.dependencies]
click = ">=8.1.3"
duckdb = ">=1.2.2"
mcp = ">=1.8.0"
prompt-toolkit = ">=3.0.0"
psutil = ">=7.0.0"
pydantic = ">=2.5.1"
requests = ">=2.28.0"
rich = ">=12.0.0"
ruamel-yaml = ">=0.18.10"
watchfiles = ">=1.0.4"
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -6500,8 +6430,8 @@ files = [
[package.dependencies]
googleapis-common-protos = ">=1.52,<2.0"
grpcio = [
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
]
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.34.0"
@@ -8937,82 +8867,6 @@ files = [
[package.dependencies]
pyasn1 = ">=0.1.3"
[[package]]
name = "ruamel-yaml"
version = "0.18.12"
description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "ruamel.yaml-0.18.12-py3-none-any.whl", hash = "sha256:790ba4c48b6a6e6b12b532a7308779eb12d2aaab3a80fdb8389216f28ea2b287"},
{file = "ruamel.yaml-0.18.12.tar.gz", hash = "sha256:5a38fd5ce39d223bebb9e3a6779e86b9427a03fb0bf9f270060f8b149cffe5e2"},
]
[package.dependencies]
"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""}
[package.extras]
docs = ["mercurial (>5.7)", "ryd"]
jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"]
[[package]]
name = "ruamel-yaml-clib"
version = "0.2.12"
description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_python_implementation == \"CPython\""
files = [
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
]
[[package]]
name = "ruff"
version = "0.11.11"
@@ -11760,4 +11614,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "d9f6c24fa80dd191f180af0c802ea11ecf514d86aaa421cb19a9bb497362c101"
content-hash = "8c960ca43a540bfd96dc029d45fa4e0a4a3f75c2996feecaa8b989c348655f70"
-2
View File
@@ -48,7 +48,6 @@ dirhash = "*"
tornado = "*"
python-dotenv = "*"
rapidfuzz = "^3.9.0"
rich = "^13.7.0"
whatthepatch = "^1.0.6"
protobuf = "^5.0.0,<6.0.0" # Updated to support newer opentelemetry
opentelemetry-api = "^1.33.1"
@@ -68,7 +67,6 @@ poetry = "^2.1.2"
anyio = "4.9.0"
pythonnet = "*"
fastmcp = "^2.5.2"
mcpm = "1.12.0"
python-frontmatter = "^1.1.0"
# TODO: Should these go into the runtime group?
ipywidgets = "^8.1.5"
+11 -4
View File
@@ -16,6 +16,16 @@ from openhands.events.action import CmdRunAction
from openhands.events.observation import CmdOutputObservation, ErrorObservation
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
from openhands.runtime.impl.local.local_runtime import LocalRuntime
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected timeout suffix."""
return (
f'[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
# ============================================================================================================================
# Bash-specific tests
@@ -56,10 +66,7 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
if runtime_cls == CLIRuntime:
assert '[The command timed out after 1.0 seconds.]' in obs.metadata.suffix
else:
assert (
"[The command timed out after 1.0 seconds. You may wait longer to see additional output by sending empty command '', send other commands to interact with the current process, or send keys to interrupt/kill the command.]"
in obs.metadata.suffix
)
assert get_timeout_suffix(1.0) in obs.metadata.suffix
action = CmdRunAction(command='C-c', is_input=True)
action.set_hard_timeout(30)
+13 -5
View File
@@ -114,9 +114,11 @@ def test_default_activated_tools():
)
with open(mcp_config_path, 'r') as f:
mcp_config = json.load(f)
assert 'default' in mcp_config
assert 'mcpServers' in mcp_config
assert 'default' in mcp_config['mcpServers']
assert 'tools' in mcp_config
# no tools are always activated yet
assert len(mcp_config['default']) == 0
assert len(mcp_config['tools']) == 0
@pytest.mark.asyncio
@@ -249,7 +251,11 @@ async def test_both_stdio_and_sse_mcp(
assert obs_cat.exit_code == 0
mcp_action_fetch = MCPAction(
name='fetch', arguments={'url': 'http://localhost:8000'}
# NOTE: the tool name is `fetch_fetch` because the tool name is `fetch`
# And FastMCP Proxy will pre-pend the server name (in this case, `fetch`)
# to the tool name, so the full tool name becomes `fetch_fetch`
name='fetch',
arguments={'url': 'http://localhost:8000'},
)
obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch)
logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'})
@@ -304,7 +310,9 @@ async def test_microagent_and_one_stdio_mcp_in_config(
logger.info(f'updated_config: {updated_config}')
# ======= Test the stdio server in the config =======
mcp_action_sse = MCPAction(name='list_directory', arguments={'path': '/'})
mcp_action_sse = MCPAction(
name='filesystem_list_directory', arguments={'path': '/'}
)
obs_sse = await runtime.call_tool_mcp(mcp_action_sse)
logger.info(obs_sse, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs_sse, MCPObservation), (
@@ -332,7 +340,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
assert obs_cat.exit_code == 0
mcp_action_fetch = MCPAction(
name='fetch', arguments={'url': 'http://localhost:8000'}
name='fetch_fetch', arguments={'url': 'http://localhost:8000'}
)
obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch)
logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'})
+178 -1
View File
@@ -1,6 +1,7 @@
"""Tests for microagent loading in runtime."""
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
@@ -13,7 +14,13 @@ from conftest import (
from openhands.core.config import MCPConfig
from openhands.core.config.mcp_config import MCPStdioServerConfig
from openhands.mcp.utils import add_mcp_tools_to_agent
from openhands.microagent import KnowledgeMicroagent, RepoMicroagent
from openhands.microagent.microagent import (
BaseMicroagent,
KnowledgeMicroagent,
RepoMicroagent,
TaskMicroagent,
)
from openhands.microagent.types import MicroagentType
def _create_test_microagents(test_dir: str):
@@ -173,6 +180,176 @@ Repository-specific test instructions.
_close_test_runtime(runtime)
def test_task_microagent_creation():
"""Test that a TaskMicroagent is created correctly."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /test_task
inputs:
- name: TEST_VAR
description: "Test variable"
---
This is a test task microagent with a variable: ${test_var}.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
assert agent.type == MicroagentType.TASK
assert agent.name == 'test_task'
assert '/test_task' in agent.triggers
assert "If the user didn't provide any of these variables" in agent.content
def test_task_microagent_variable_extraction():
"""Test that variables are correctly extracted from the content."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /test_task
inputs:
- name: var1
description: "Variable 1"
---
This is a test with variables: ${var1}, ${var2}, and ${var3}.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
variables = agent.extract_variables(agent.content)
assert set(variables) == {'var1', 'var2', 'var3'}
assert agent.requires_user_input()
def test_knowledge_microagent_no_prompt():
"""Test that a regular KnowledgeMicroagent doesn't get the prompt."""
content = """---
name: test_knowledge
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- test_knowledge
---
This is a test knowledge microagent.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, KnowledgeMicroagent)
assert agent.type == MicroagentType.KNOWLEDGE
assert "If the user didn't provide any of these variables" not in agent.content
def test_task_microagent_trigger_addition():
"""Test that a trigger is added if not present."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: TEST_VAR
description: "Test variable"
---
This is a test task microagent.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
assert '/test_task' in agent.triggers
def test_task_microagent_no_duplicate_trigger():
"""Test that a trigger is not duplicated if already present."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /test_task
- another_trigger
inputs:
- name: TEST_VAR
description: "Test variable"
---
This is a test task microagent.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
assert agent.triggers.count('/test_task') == 1 # No duplicates
assert len(agent.triggers) == 2
assert 'another_trigger' in agent.triggers
assert '/test_task' in agent.triggers
def test_task_microagent_match_trigger():
"""Test that a task microagent matches its trigger correctly."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /test_task
inputs:
- name: TEST_VAR
description: "Test variable"
---
This is a test task microagent.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
assert agent.match_trigger('/test_task') == '/test_task'
assert agent.match_trigger(' /test_task ') == '/test_task'
assert agent.match_trigger('This contains /test_task') == '/test_task'
assert agent.match_trigger('/other_task') is None
def test_default_tools_microagent_exists():
"""Test that the default-tools microagent exists in the global microagents directory."""
# Get the path to the global microagents directory
+1 -1
View File
@@ -589,7 +589,7 @@
"working_dir": null,
"py_interpreter_path": null,
"prefix": "",
"suffix": "\n[The command has no new output after 30 seconds. You may wait longer to see additional output by sending empty command '', send other commands to interact with the current process, or send keys to interrupt/kill the command.]"
"suffix": "\n[The command has no new output after 30 seconds. You may wait longer to see additional output by sending empty command '', send other commands to interact with the current process, send keys to interrupt/kill the command, or use the timeout parameter in execute_bash for future commands.]"
},
"hidden": false
},
+16 -42
View File
@@ -5,6 +5,15 @@ import time
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import CmdRunAction
from openhands.runtime.utils.bash import BashCommandStatus, BashSession
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_no_change_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected no-change timeout suffix."""
return (
f'\n[The command has no new output after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
def test_session_initialization():
@@ -83,12 +92,7 @@ def test_long_running_command_follow_by_execute():
assert '1' in obs.content # First number should appear before timeout
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == (
'\n[The command has no new output after 2 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.prefix == ''
# Continue watching output
@@ -96,12 +100,7 @@ def test_long_running_command_follow_by_execute():
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '2' in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert obs.metadata.suffix == (
'\n[The command has no new output after 2 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
@@ -142,12 +141,7 @@ def test_interactive_command():
assert 'Enter name:' in obs.content
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == ''
# Send input
@@ -164,36 +158,21 @@ def test_interactive_command():
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == ''
obs = session.execute(CmdRunAction('line 1', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('line 2', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('EOF', is_input=True))
@@ -216,12 +195,7 @@ def test_ctrl_c():
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'looping' in obs.content
assert obs.metadata.suffix == (
'\n[The command has no new output after 2 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.prefix == ''
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
-49
View File
@@ -1,49 +0,0 @@
import asyncio
from contextlib import asynccontextmanager
from unittest import mock
import pytest
from openhands.mcp.client import MCPClient
@pytest.mark.asyncio
async def test_connect_sse_timeout():
"""Test that connect_sse properly times out when server_url is invalid."""
client = MCPClient()
# Create a mock async context manager that simulates a timeout
@asynccontextmanager
async def mock_slow_context(*args, **kwargs):
# This will hang for longer than our timeout
await asyncio.sleep(10.0)
yield (mock.AsyncMock(), mock.AsyncMock())
# Patch the sse_client function to return our slow context manager
with mock.patch(
'openhands.mcp.client.sse_client', return_value=mock_slow_context()
):
# Test with a very short timeout
with pytest.raises(asyncio.TimeoutError):
await client.connect_sse('http://example.com', timeout=0.1)
@pytest.mark.asyncio
async def test_connect_streamable_http_timeout():
"""Test that connect_streamable_http properly times out when server_url is invalid."""
client = MCPClient()
# Create a mock async context manager that simulates a timeout
@asynccontextmanager
async def mock_slow_context(*args, **kwargs):
# This will hang for longer than our timeout
await asyncio.sleep(10.0)
yield (mock.AsyncMock(), mock.AsyncMock(), mock.AsyncMock())
# Patch the streamablehttp_client function to return our slow context manager
with mock.patch(
'openhands.mcp.client.streamablehttp_client', return_value=mock_slow_context()
):
# Test with a very short timeout
with pytest.raises(asyncio.TimeoutError):
await client.connect_shttp('http://example.com', timeout=0.1)

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