Compare commits

..

1 Commits

Author SHA1 Message Date
openhands c92f71c8e2 fix: Properly propagate AgentRuntimeTimeoutError to evaluation loop 2025-07-26 17:01:34 +00:00
105 changed files with 3807 additions and 5234 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
node-version: 22
fail-fast: true
steps:
- name: Checkout
+36 -135
View File
@@ -1,158 +1,59 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
echo "This hook runs selective linting based on changed files."
echo "This hook runs 'make lint' to ensure code quality before committing."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# Get the list of staged files
STAGED_FILES=$(git diff --cached --name-only)
# Run make lint to check both frontend and backend code
echo "Running linting checks with 'make lint'..."
make lint
if [ $? -ne 0 ]; then
echo "Linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Linting checks passed!"
fi
# Check if any files match specific patterns
has_frontend_changes=false
has_backend_changes=false
has_vscode_changes=false
# Check if frontend directory has changed
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
if [ -n "$frontend_changes" ]; then
echo "Frontend changes detected. Running additional frontend checks..."
# Check each file individually to avoid issues with grep
for file in $STAGED_FILES; do
if [[ $file == frontend/* ]]; then
has_frontend_changes=true
elif [[ $file == openhands/* || $file == evaluation/* || $file == tests/* ]]; then
has_backend_changes=true
# Check for VSCode extension changes (subset of backend changes)
if [[ $file == openhands/integrations/vscode/* ]]; then
has_vscode_changes=true
fi
fi
done
# Check if frontend directory exists
if [ -d "frontend" ]; then
# Change to frontend directory
cd frontend || exit 1
echo "Analyzing changes..."
echo "- Frontend changes: $has_frontend_changes"
echo "- Backend changes: $has_backend_changes"
echo "- VSCode extension changes: $has_vscode_changes"
# Run frontend linting if needed
if [ "$has_frontend_changes" = true ]; then
# Check if we're in a CI environment or if frontend dependencies are missing
if [ -n "$CI" ] || ! command -v react-router &> /dev/null || ! command -v vitest &> /dev/null; then
echo "Skipping frontend checks (CI environment or missing dependencies detected)."
echo "WARNING: Frontend files have changed but frontend checks are being skipped."
echo "Please run 'make lint-frontend' manually before submitting your PR."
else
echo "Running frontend linting..."
make lint-frontend
# Run build
echo "Running npm build..."
npm run build
if [ $? -ne 0 ]; then
echo "Frontend linting failed. Please fix the issues before committing."
echo "Frontend build failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Frontend linting checks passed!"
fi
# Run additional frontend checks
if [ -d "frontend" ]; then
echo "Running additional frontend checks..."
cd frontend || exit 1
# Run build
echo "Running npm build..."
npm run build
if [ $? -ne 0 ]; then
echo "Frontend build failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run tests
echo "Running npm test..."
npm test
if [ $? -ne 0 ]; then
echo "Frontend tests failed. Please fix the failing tests before committing."
EXIT_CODE=1
fi
cd ..
fi
fi
else
echo "Skipping frontend checks (no frontend changes detected)."
fi
# Run backend linting if needed
if [ "$has_backend_changes" = true ]; then
echo "Running backend linting..."
make lint-backend
if [ $? -ne 0 ]; then
echo "Backend linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Backend linting checks passed!"
fi
else
echo "Skipping backend checks (no backend changes detected)."
fi
# Run VSCode extension checks if needed
if [ "$has_vscode_changes" = true ]; then
# Check if we're in a CI environment
if [ -n "$CI" ]; then
echo "Skipping VSCode extension checks (CI environment detected)."
echo "WARNING: VSCode extension files have changed but checks are being skipped."
echo "Please run VSCode extension checks manually before submitting your PR."
else
echo "Running VSCode extension checks..."
if [ -d "openhands/integrations/vscode" ]; then
cd openhands/integrations/vscode || exit 1
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "VSCode extension linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension linting passed!"
fi
echo "Running npm typecheck..."
npm run typecheck
if [ $? -ne 0 ]; then
echo "VSCode extension type checking failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension type checking passed!"
fi
echo "Running npm compile..."
npm run compile
if [ $? -ne 0 ]; then
echo "VSCode extension compilation failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension compilation passed!"
fi
cd ../../..
fi
fi
else
echo "Skipping VSCode extension checks (no VSCode extension changes detected)."
fi
# If no specific code changes detected, run basic checks
if [ "$has_frontend_changes" = false ] && [ "$has_backend_changes" = false ]; then
echo "No specific code changes detected. Running basic checks..."
if [ -n "$STAGED_FILES" ]; then
# Run only basic pre-commit hooks for non-code files
poetry run pre-commit run --files $(echo "$STAGED_FILES" | tr '\n' ' ') --hook-stage commit --config ./dev_config/python/.pre-commit-config.yaml
# Run tests
echo "Running npm test..."
npm test
if [ $? -ne 0 ]; then
echo "Basic checks failed. Please fix the issues before committing."
echo "Frontend tests failed. Please fix the failing tests before committing."
EXIT_CODE=1
else
echo "Basic checks passed!"
fi
# Return to the original directory
cd ..
if [ $EXIT_CODE -eq 0 ]; then
echo "Frontend checks passed!"
fi
else
echo "No files changed. Skipping basic checks."
echo "Frontend directory not found. Skipping frontend checks."
fi
else
echo "No frontend changes detected. Skipping additional frontend checks."
fi
# Run any existing pre-commit hooks that might have been installed by the user
+4 -4
View File
@@ -174,7 +174,7 @@ install-python-dependencies:
fi
@echo "$(GREEN)Python dependencies installed successfully.$(RESET)"
install-frontend-dependencies: check-npm check-nodejs
install-frontend-dependencies:
@echo "$(YELLOW)Setting up frontend environment...$(RESET)"
@echo "$(YELLOW)Detect Node.js version...$(RESET)"
@cd frontend && node ./scripts/detect-node-version.js
@@ -182,17 +182,17 @@ install-frontend-dependencies: check-npm check-nodejs
@cd frontend && npm install
@echo "$(GREEN)Frontend dependencies installed successfully.$(RESET)"
install-pre-commit-hooks: check-python check-poetry install-python-dependencies
install-pre-commit-hooks:
@echo "$(YELLOW)Installing pre-commit hooks...$(RESET)"
@git config --unset-all core.hooksPath || true
@poetry run pre-commit install --config $(PRE_COMMIT_CONFIG_PATH)
@echo "$(GREEN)Pre-commit hooks installed successfully.$(RESET)"
lint-backend: install-pre-commit-hooks
lint-backend:
@echo "$(YELLOW)Running linters...$(RESET)"
@poetry run pre-commit run --all-files --show-diff-on-failure --config $(PRE_COMMIT_CONFIG_PATH)
lint-frontend: install-frontend-dependencies
lint-frontend:
@echo "$(YELLOW)Running linters for frontend...$(RESET)"
@cd frontend && npm run lint
@@ -8,29 +8,6 @@ description: This guide walks you through the process of installing OpenHands Cl
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
## IP Whitelisting
If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist the following IP addresses to allow OpenHands to access your repositories:
### Core App IP
```
34.68.58.200
```
### Runtime IPs
```
34.10.175.217
34.136.162.246
34.45.0.142
34.28.69.126
35.224.240.213
34.70.174.52
34.42.4.87
35.222.133.153
34.29.175.97
34.60.55.59
```
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
+1 -1
View File
@@ -24,7 +24,7 @@ description: This guide walks you through installing the OpenHands Slack app.
**This step is for Slack admins/owners**
1. Make sure you have permissions to install Apps to your workspace.
2. Click the button below to install OpenHands Slack App <a target="_blank" href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope="><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
2. Click the button below to install OpenHands Slack App <a target="_blank" href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,chat:write,users:read,channels:history,groups:history,mpim:history,im:history&user_scope=channels:history,groups:history,im:history,mpim:history"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
3. In the top right corner, select the workspace to install the OpenHands Slack app.
4. Review permissions and click allow.
+2 -3
View File
@@ -30,6 +30,5 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
## Pricing
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: $0.4 per million input tokens and $1.6 per million output tokens.
Pricing follows official API provider rates.
[You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
+17 -69
View File
@@ -10,7 +10,6 @@ Model Context Protocol (MCP) is a mechanism that allows OpenHands to communicate
servers can provide additional functionality to the agent, such as specialized data processing, external API access,
or custom tools. MCP is based on the open standard defined at [modelcontextprotocol.io](https://modelcontextprotocol.io).
<Note>
MCP is currently not available on OpenHands Cloud. This feature is only available when running OpenHands locally.
</Note>
@@ -36,57 +35,41 @@ MCP configuration can be defined in:
* The OpenHands UI through the Settings under the `MCP` tab.
* The `config.toml` file under the `[mcp]` section if not using the UI.
### Configuration Examples
#### Recommended: Using Proxy Servers (SSE/HTTP)
For stdio-based MCP servers, we recommend using MCP proxy tools like [`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to HTTP/SSE endpoints:
Start the proxy servers separately:
```bash
# Terminal 1: Filesystem server proxy
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
# Terminal 2: Fetch server proxy
supergateway --stdio "uvx mcp-server-fetch" --port 8081
```
Then configure OpenHands to use the HTTP endpoint:
### Configuration Example via config.toml
```toml
[mcp]
# SSE Servers - Recommended approach using proxy tools
# SSE Servers - External servers that communicate via Server-Sent Events
sse_servers = [
# Basic SSE server with just a URL
"http://example.com:8080/mcp",
# SuperGateway proxy for fetch server
"http://localhost:8081/sse",
# External MCP service with authentication
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
# SSE server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
```
# SHTTP Servers - External servers that communicate via Streamable HTTP
shttp_servers = [
# Basic SHTTP server with just a URL
"http://example.com:8080/mcp",
# SHTTP server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
#### Alternative: Direct Stdio Servers (Not Recommended for Production)
```toml
[mcp]
# Direct stdio servers - use only for development/testing
# Stdio Servers - Local processes that communicate via standard input/output
stdio_servers = [
# Basic stdio server
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
# Stdio server with environment variables
{
name="filesystem",
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/"],
name="data-processor",
command="python",
args=["-m", "my_mcp_server"],
env={
"DEBUG": "true"
"DEBUG": "true",
"PORT": "8080"
}
}
]
@@ -120,8 +103,6 @@ SHTTP (Streamable HTTP) servers are configured using either a string URL or an o
### Stdio Servers
**Note**: While stdio servers are supported, we recommend using MCP proxies (see above) for better reliability and performance.
Stdio servers are configured using an object with the following properties:
- `name` (required)
@@ -142,39 +123,6 @@ Stdio servers are configured using an object with the following properties:
- Default: `{}`
- Description: Environment variables to set for the server process
#### When to Use Direct Stdio
Direct stdio connections may still be appropriate in these scenarios:
- **Development and testing**: Quick prototyping of MCP servers
- **Simple, single-use tools**: Tools that don't require high reliability or concurrent access
- **Local-only environments**: When you don't want to manage additional proxy processes
For production use, we recommend using proxy tools like SuperGateway.
### Other Proxy Tools
Other options include:
- **Custom FastAPI/Express servers**: Build your own HTTP wrapper around stdio MCP servers
- **Docker-based proxies**: Containerized solutions for better isolation
- **Cloud-hosted MCP services**: Third-party services that provide MCP endpoints
### Troubleshooting MCP Connections
#### Common Issues with Stdio Servers
- **Process crashes**: Stdio processes may crash without proper error handling
- **Deadlocks**: Stdio communication can deadlock under high load
- **Resource leaks**: Zombie processes if not properly managed
- **Debugging difficulty**: Hard to inspect stdio communication
#### Benefits of Using Proxies
- **HTTP status codes**: Clear error reporting via standard HTTP responses
- **Request logging**: Easy to log and monitor HTTP requests
- **Load balancing**: Can distribute requests across multiple server instances
- **Health checks**: HTTP endpoints can provide health status
- **CORS support**: Better integration with web-based tools
## Transport Protocols
OpenHands supports three different MCP transport protocols:
+5 -2
View File
@@ -641,7 +641,9 @@ def process_instance(
)
)
# if fatal error, throw EvalError to trigger re-run
# if state is None or has a fatal error, throw EvalError to trigger re-run
if state is None:
raise EvalException('State is None, likely due to a runtime error')
if is_fatal_evaluation_error(state.last_error):
raise EvalException('Fatal error detected: ' + state.last_error)
@@ -671,8 +673,9 @@ def process_instance(
# If you are working on some simpler benchmark that only evaluates the final model output (e.g., in a MessageAction)
# You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation.
# This check is redundant since we already check above, but keeping it for safety
if state is None:
raise ValueError('State should not be None.')
raise EvalException('State is None, likely due to a runtime error')
# NOTE: this is NO LONGER the event stream, but an agent history that includes delegate agent's events
histories = [event_to_dict(event) for event in state.history]
+1 -1
View File
@@ -8,4 +8,4 @@ npx lint-staged
# Run backend pre-commit
echo "Running backend pre-commit..."
cd ..
poetry run pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
@@ -28,6 +28,7 @@ describe("EventMessage", () => {
action: "finish" as const,
args: {
final_thought: "Task completed successfully",
task_completed: "success" as const,
outputs: {},
thought: "Task completed successfully",
},
@@ -113,6 +114,7 @@ describe("EventMessage", () => {
action: "finish" as const,
args: {
final_thought: "Task completed successfully",
task_completed: "success" as const,
outputs: {},
thought: "Task completed successfully",
},
@@ -72,7 +72,6 @@ describe("HomeHeader", () => {
undefined,
undefined,
undefined,
undefined,
);
// expect to be redirected to /conversations/:conversationId
@@ -209,7 +209,6 @@ describe("RepoConnector", () => {
undefined,
"main",
undefined,
undefined,
);
});
@@ -97,7 +97,6 @@ describe("TaskCard", () => {
},
undefined,
undefined,
undefined,
);
});
});
@@ -1,52 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { time?: string }) => {
const translations: Record<string, string> = {
"MAINTENANCE$SCHEDULED_MESSAGE": `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
};
return translations[key] || key;
},
}),
};
});
describe("MaintenanceBanner", () => {
it("renders maintenance banner with formatted time", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const { container } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument();
// Check if the warning icon (SVG) is present
const svgIcon = container.querySelector('svg');
expect(svgIcon).toBeInTheDocument();
});
it("handles invalid date gracefully", () => {
const invalidTime = "invalid-date";
render(<MaintenanceBanner startTime={invalidTime} />);
// Should still render the banner with the original string
expect(screen.getByText(/Scheduled maintenance will begin at invalid-date/)).toBeInTheDocument();
});
it("formats ISO date string correctly", () => {
const isoTime = "2024-01-15T15:30:00.000Z";
render(<MaintenanceBanner startTime={isoTime} />);
// Should render the banner (exact time format will depend on user's timezone)
expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument();
});
});
@@ -73,73 +73,4 @@ describe("TrajectoryActions", () => {
expect(onExportTrajectory).toHaveBeenCalled();
});
describe("SaaS mode", () => {
it("should only render export button when isSaasMode is true", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const actions = screen.getByTestId("feedback-actions");
// Should not render feedback buttons in SaaS mode
expect(within(actions).queryByTestId("positive-feedback")).toBeNull();
expect(within(actions).queryByTestId("negative-feedback")).toBeNull();
// Should still render export button
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is false", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={false}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is undefined (default behavior)", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should call onExportTrajectory when export button is clicked in SaaS mode", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});
});
+12 -18
View File
@@ -222,12 +222,10 @@ describe("HomeScreen", () => {
// All other buttons should be disabled when the header button is clicked
await userEvent.click(headerLaunchButton);
await waitFor(() => {
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
@@ -242,12 +240,10 @@ describe("HomeScreen", () => {
// All other buttons should be disabled when the repo button is clicked
await userEvent.click(repoLaunchButton);
await waitFor(() => {
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
@@ -262,12 +258,10 @@ describe("HomeScreen", () => {
// All other buttons should be disabled when the task button is clicked
await userEvent.click(tasksLaunchButtons[0]);
await waitFor(() => {
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
});
@@ -366,17 +366,17 @@ describe("Form submission", () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
await screen.findByTestId("llm-settings-form-advanced");
screen.getByTestId("llm-settings-form-advanced");
const submitButton = await screen.findByTestId("submit-button");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = await screen.findByTestId("llm-custom-model-input");
const baseUrl = await screen.findByTestId("base-url-input");
const apiKey = await screen.findByTestId("llm-api-key-input");
const agent = await screen.findByTestId("agent-input");
const confirmation = await screen.findByTestId("enable-confirmation-mode-switch");
const condensor = await screen.findByTestId("enable-memory-condenser-switch");
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.type(model, "-mini");
@@ -449,7 +449,7 @@ describe("Form submission", () => {
expect(submitButton).toBeDisabled();
// select security analyzer
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
+1403 -1178
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -7,15 +7,15 @@
"node": ">=22.0.0"
},
"dependencies": {
"@heroui/react": "^2.8.2",
"@heroui/react": "^2.8.1",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"@react-types/shared": "^3.31.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.8.1",
"@stripe/stripe-js": "^7.7.0",
"@stripe/react-stripe-js": "^3.8.0",
"@stripe/stripe-js": "^7.6.1",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.83.0",
@@ -25,17 +25,17 @@
"axios": "^1.11.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.12",
"framer-motion": "^12.23.9",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.29",
"isbot": "^5.1.28",
"jose": "^6.0.12",
"lucide-react": "^0.534.0",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.258.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"posthog-js": "^1.258.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.6.1",
@@ -88,13 +88,13 @@
"@react-router/dev": "^7.7.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -102,7 +102,7 @@
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.2.3",
"autoprefixer": "^10.4.21",
"cross-env": "^10.0.0",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
-3
View File
@@ -56,9 +56,6 @@ export interface GetConfigResponse {
HIDE_LLM_SETTINGS: boolean;
HIDE_MICROAGENT_MANAGEMENT?: boolean;
};
MAINTENANCE?: {
startTime: string;
};
}
export interface GetVSCodeUrlResponse {
@@ -232,16 +232,17 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
isSaasMode={config?.APP_MODE === "saas"}
/>
{config?.APP_MODE !== "saas" && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
/>
)}
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
@@ -77,8 +77,25 @@ const getMcpActionContent = (event: MCPAction): string => {
const getThinkActionContent = (event: ThinkAction): string =>
event.args.thought;
const getFinishActionContent = (event: FinishAction): string =>
event.args.final_thought.trim();
const getFinishActionContent = (event: FinishAction): string => {
let content = event.args.final_thought;
switch (event.args.task_completed) {
case "success":
content += `\n\n\n${i18n.t("FINISH$TASK_COMPLETED_SUCCESSFULLY")}`;
break;
case "failure":
content += `\n\n\n${i18n.t("FINISH$TASK_NOT_COMPLETED")}`;
break;
case "partial":
default:
content += `\n\n\n${i18n.t("FINISH$TASK_COMPLETED_PARTIALLY")}`;
break;
}
return content.trim();
};
const getNoContentActionContent = (): string => "";
export const getActionContent = (event: OpenHandsAction): string => {
@@ -1,22 +0,0 @@
import { cn } from "#/utils/utils";
interface ContextMenuIconTextProps {
icon: React.ComponentType<{ className?: string }>;
text: string;
className?: string;
iconClassName?: string;
}
export function ContextMenuIconText({
icon: Icon,
text,
className,
iconClassName,
}: ContextMenuIconTextProps) {
return (
<div className={cn("flex items-center gap-3 px-1", className)}>
<Icon className={cn("w-4 h-4 shrink-0", iconClassName)} />
{text}
</div>
);
}
@@ -19,7 +19,7 @@ export function ContextMenuListItem({
onClick={onClick}
disabled={isDisabled}
className={cn(
"text-sm px-4 h-10 w-full text-start hover:bg-white/10 cursor-pointer",
"text-sm px-4 py-2 w-full text-start hover:bg-white/10 first-of-type:rounded-t-md last-of-type:rounded-b-md",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent text-nowrap",
)}
>
@@ -18,7 +18,7 @@ export function ContextMenu({
<ul
data-testid={testId}
ref={ref}
className={cn("bg-tertiary rounded-md overflow-hidden", className)}
className={cn("bg-tertiary rounded-md", className)}
>
{children}
</ul>
@@ -1,20 +1,9 @@
import {
Trash,
Power,
Pencil,
Download,
Wallet,
Wrench,
Bot,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { ContextMenuSeparator } from "../context-menu/context-menu-separator";
import { I18nKey } from "#/i18n/declaration";
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
interface ConversationCardContextMenuProps {
onClose: () => void;
@@ -42,12 +31,6 @@ export function ConversationCardContextMenu({
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const hasEdit = Boolean(onEdit);
const hasDownload = Boolean(onDownloadViaVSCode);
const hasTools = Boolean(onShowAgentTools || onShowMicroagents);
const hasInfo = Boolean(onDisplayCost);
const hasControl = Boolean(onStop || onDelete);
return (
<ContextMenu
ref={ref}
@@ -58,84 +41,51 @@ export function ConversationCardContextMenu({
position === "bottom" && "top-full",
)}
>
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
<ContextMenuIconText
icon={Pencil}
text={t(I18nKey.BUTTON$EDIT_TITLE)}
/>
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
{t(I18nKey.BUTTON$DELETE)}
</ContextMenuListItem>
)}
{hasEdit && (hasDownload || hasTools || hasInfo || hasControl) && (
<ContextMenuSeparator />
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
{t(I18nKey.BUTTON$STOP)}
</ContextMenuListItem>
)}
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
{t(I18nKey.BUTTON$EDIT_TITLE)}
</ContextMenuListItem>
)}
{onDownloadViaVSCode && (
<ContextMenuListItem
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
>
<ContextMenuIconText
icon={Download}
text={t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
/>
{t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
</ContextMenuListItem>
)}
{hasDownload && (hasTools || hasInfo || hasControl) && (
<ContextMenuSeparator />
)}
{onShowAgentTools && (
<ContextMenuListItem
testId="show-agent-tools-button"
onClick={onShowAgentTools}
>
<ContextMenuIconText
icon={Wrench}
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
/>
</ContextMenuListItem>
)}
{onShowMicroagents && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
>
<ContextMenuIconText
icon={Bot}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
/>
</ContextMenuListItem>
)}
{hasTools && (hasInfo || hasControl) && <ContextMenuSeparator />}
{onDisplayCost && (
<ContextMenuListItem
testId="display-cost-button"
onClick={onDisplayCost}
>
<ContextMenuIconText
icon={Wallet}
text={t(I18nKey.BUTTON$DISPLAY_COST)}
/>
{t(I18nKey.BUTTON$DISPLAY_COST)}
</ContextMenuListItem>
)}
{hasInfo && hasControl && <ContextMenuSeparator />}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$STOP)} />
{onShowAgentTools && (
<ContextMenuListItem
testId="show-agent-tools-button"
onClick={onShowAgentTools}
>
{t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
</ContextMenuListItem>
)}
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
<ContextMenuIconText icon={Trash} text={t(I18nKey.BUTTON$DELETE)} />
{onShowMicroagents && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
>
{t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
</ContextMenuListItem>
)}
</ContextMenu>
@@ -1,69 +0,0 @@
import { useTranslation } from "react-i18next";
import { FaTriangleExclamation } from "react-icons/fa6";
interface MaintenanceBannerProps {
startTime: string;
}
export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) {
const { t } = useTranslation();
// Convert EST timestamp to user's local timezone
const formatMaintenanceTime = (estTimeString: string): string => {
try {
// Parse the EST timestamp
// If the string doesn't include timezone info, assume it's EST
let dateToFormat: Date;
if (
estTimeString.includes("T") &&
(estTimeString.includes("-05:00") ||
estTimeString.includes("-04:00") ||
estTimeString.includes("EST") ||
estTimeString.includes("EDT"))
) {
// Already has timezone info
dateToFormat = new Date(estTimeString);
} else {
// Assume EST and convert to UTC for proper parsing
// EST is UTC-5, EDT is UTC-4, but we'll assume EST for simplicity
const estDate = new Date(estTimeString);
if (Number.isNaN(estDate.getTime())) {
throw new Error("Invalid date");
}
dateToFormat = estDate;
}
// Format to user's local timezone
return dateToFormat.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
} catch (error) {
// Fallback to original string if parsing fails
// eslint-disable-next-line no-console
console.warn("Failed to parse maintenance time:", error);
return estTimeString;
}
};
const localTime = formatMaintenanceTime(startTime);
return (
<div className="bg-primary text-[#0D0F11] p-4 rounded">
<div className="flex items-center">
<div className="flex-shrink-0">
<FaTriangleExclamation className="text-white align-middle" />
</div>
<div className="ml-3">
<p className="text-sm font-medium">
{t("MAINTENANCE$SCHEDULED_MESSAGE", { time: localTime })}
</p>
</div>
</div>
</div>
);
}
@@ -8,7 +8,7 @@ export function InstallSlackAppAnchor() {
return (
<a
data-testid="install-slack-app-button"
href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope="
href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,chat:write,users:read,channels:history,groups:history,mpim:history,im:history&user_scope=channels:history,groups:history,im:history,mpim:history"
target="_blank"
rel="noreferrer noopener"
className="py-9"
@@ -9,35 +9,29 @@ interface TrajectoryActionsProps {
onPositiveFeedback: () => void;
onNegativeFeedback: () => void;
onExportTrajectory: () => void;
isSaasMode?: boolean;
}
export function TrajectoryActions({
onPositiveFeedback,
onNegativeFeedback,
onExportTrajectory,
isSaasMode = false,
}: TrajectoryActionsProps) {
const { t } = useTranslation();
return (
<div data-testid="feedback-actions" className="flex gap-1">
{!isSaasMode && (
<>
<TrajectoryActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
/>
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
/>
</>
)}
<TrajectoryActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
/>
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
/>
<TrajectoryActionButton
testId="export-trajectory"
onClick={onExportTrajectory}
+4 -18
View File
@@ -22,18 +22,8 @@ const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
commands: [],
};
const renderCommand = (
command: Command,
terminal: Terminal,
isUserInput: boolean = false,
) => {
const { content, type } = command;
// Skip rendering user input commands that come from the event stream
// as they've already been displayed in the terminal as the user typed
if (type === "input" && isUserInput) {
return;
}
const renderCommand = (command: Command, terminal: Terminal) => {
const { content } = command;
terminal.writeln(
parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()),
@@ -133,9 +123,7 @@ export const useTerminal = ({
if (commands[i].type === "input") {
terminal.current.write("$ ");
}
// Don't pass isUserInput=true here because we're initializing the terminal
// and need to show all previous commands
renderCommand(commands[i], terminal.current, false);
renderCommand(commands[i], terminal.current);
}
lastCommandIndex.current = commands.length;
}
@@ -156,9 +144,7 @@ export const useTerminal = ({
let lastCommandType = "";
for (let i = lastCommandIndex.current; i < commands.length; i += 1) {
lastCommandType = commands[i].type;
// Pass true for isUserInput to skip rendering user input commands
// that have already been displayed as the user typed
renderCommand(commands[i], terminal.current, true);
renderCommand(commands[i], terminal.current);
}
lastCommandIndex.current = commands.length;
if (lastCommandType === "output") {
-1
View File
@@ -1,6 +1,5 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
MAINTENANCE$SCHEDULED_MESSAGE = "MAINTENANCE$SCHEDULED_MESSAGE",
MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND",
MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT",
MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD",
+14 -30
View File
@@ -1,20 +1,4 @@
{
"MAINTENANCE$SCHEDULED_MESSAGE": {
"en": "Scheduled maintenance will begin at {{time}}",
"ja": "予定されたメンテナンスは{{time}}に開始されます",
"zh-CN": "计划维护将于{{time}}开始",
"zh-TW": "計劃維護將於{{time}}開始",
"ko-KR": "예정된 유지보수가 {{time}}에 시작됩니다",
"no": "Planlagt vedlikehold starter {{time}}",
"it": "La manutenzione programmata inizierà alle {{time}}",
"pt": "A manutenção programada começará às {{time}}",
"es": "El mantenimiento programado comenzará a las {{time}}",
"ar": "ستبدأ الصيانة المجدولة في {{time}}",
"fr": "La maintenance programmée commencera à {{time}}",
"tr": "Planlı bakım {{time}} tarihinde başlayacak",
"de": "Die geplante Wartung beginnt um {{time}}",
"uk": "Планове технічне обслуговування розпочнеться о {{time}}"
},
"MICROAGENT$NO_REPOSITORY_FOUND": {
"en": "No repository found to launch microagent",
"ja": "マイクロエージェントを起動するためのリポジトリが見つかりません",
@@ -6192,20 +6176,20 @@
"uk": "Ви можете знайти свій ключ API OpenHands у"
},
"SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX": {
"en": "tab of OpenHands Cloud. LLM usage is billed at the providers' rates with no markup. Details: https://docs.all-hands.dev/usage/llms/openhands-llms",
"ja": "タブで確認できます。LLMの使用料金は、プロバイダーの料金でマークアップなしで請求されます。詳細: https://docs.all-hands.dev/usage/llms/openhands-llms",
"zh-CN": "标签页中找到您的OpenHands API密钥。LLM使用费用按提供商费率计费,无加价。详情: https://docs.all-hands.dev/usage/llms/openhands-llms",
"zh-TW": "標籤頁中找到您的OpenHands API密鑰。LLM使用費用按提供商費率計費,無加價。詳情: https://docs.all-hands.dev/usage/llms/openhands-llms",
"ko-KR": "탭에서 찾을 수 있습니다. LLM 사용료는 제공업체 요금으로 마크업 없이 청구됩니다. 자세한 내용: https://docs.all-hands.dev/usage/llms/openhands-llms",
"no": "-fanen i OpenHands Cloud. LLM-bruk faktureres til leverandørenes priser uten påslag. Detaljer: https://docs.all-hands.dev/usage/llms/openhands-llms",
"it": "scheda di OpenHands Cloud. L'utilizzo di LLM viene fatturato alle tariffe dei fornitori senza ricarico. Dettagli: https://docs.all-hands.dev/usage/llms/openhands-llms",
"pt": "guia do OpenHands Cloud. O uso de LLM é cobrado nas tarifas dos provedores sem markup. Detalhes: https://docs.all-hands.dev/usage/llms/openhands-llms",
"es": "pestaña de OpenHands Cloud. El uso de LLM se factura a las tarifas de los proveedores sin recargo. Detalles: https://docs.all-hands.dev/usage/llms/openhands-llms",
"ar": "علامة التبويب في OpenHands Cloud. يتم فوترة استخدام LLM بأسعار المزودين بدون زيادة. التفاصيل: https://docs.all-hands.dev/usage/llms/openhands-llms",
"fr": "l'onglet d'OpenHands Cloud. L'utilisation de LLM est facturée aux tarifs des fournisseurs sans majoration. Détails : https://docs.all-hands.dev/usage/llms/openhands-llms",
"tr": "OpenHands Cloud'un sekmesinde bulabilirsiniz. LLM kullanımı, sağlayıcıların oranlarında ek ücret olmadan faturalandırılır. Ayrıntılar: https://docs.all-hands.dev/usage/llms/openhands-llms",
"de": "Tab von OpenHands Cloud. LLM-Nutzung wird zu Anbieterpreisen ohne Aufschlag abgerechnet. Details: https://docs.all-hands.dev/usage/llms/openhands-llms",
"uk": "вкладці OpenHands Cloud. Використання LLM оплачується за тарифами провайдерів без надбавки. Деталі: https://docs.all-hands.dev/usage/llms/openhands-llms"
"en": "tab of OpenHands Cloud.",
"ja": "タブで確認できます。",
"zh-CN": "标签页中找到您的OpenHands API密钥。",
"zh-TW": "標籤頁中找到您的OpenHands API密鑰。",
"ko-KR": "탭에서 찾을 수 있습니다.",
"no": "-fanen i OpenHands Cloud.",
"it": "scheda di OpenHands Cloud.",
"pt": "guia do OpenHands Cloud.",
"es": "pestaña de OpenHands Cloud.",
"ar": "علامة التبويب في OpenHands Cloud.",
"fr": "l'onglet d'OpenHands Cloud.",
"tr": "OpenHands Cloud'un sekmesinde bulabilirsiniz.",
"de": "Tab von OpenHands Cloud.",
"uk": "вкладці OpenHands Cloud."
},
"SETTINGS$CREATE_API_KEY": {
"en": "Create API Key",
-4
View File
@@ -187,10 +187,6 @@ export const handlers = [
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: mockSaas,
},
// Uncomment the following to test the maintenance banner
// MAINTENANCE: {
// startTime: "2024-01-15T10:00:00-05:00", // EST timestamp
// },
};
return HttpResponse.json(config);
-4
View File
@@ -26,7 +26,6 @@ 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";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
export function ErrorBoundary() {
const error = useRouteError();
@@ -206,9 +205,6 @@ export default function MainApp() {
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
{config.data?.MAINTENANCE && (
<MaintenanceBanner startTime={config.data.MAINTENANCE.startTime} />
)}
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
+1 -1
View File
@@ -17,6 +17,6 @@ export enum AgentState {
export const RUNTIME_INACTIVE_STATES = [
AgentState.INIT,
AgentState.LOADING,
// Removed AgentState.STOPPED to allow tabs to remain visible when agent is stopped
AgentState.STOPPED,
AgentState.ERROR,
];
+1
View File
@@ -64,6 +64,7 @@ export interface FinishAction extends OpenHandsActionEvent<"finish"> {
source: "agent";
args: {
final_thought: string;
task_completed: "success" | "failure" | "partial";
outputs: Record<string, unknown>;
thought: string;
};
-2
View File
@@ -21,7 +21,6 @@ export const VERIFIED_MODELS = [
"devstral-small-2507",
"devstral-medium-2507",
"kimi-k2-0711-preview",
"qwen3-coder-480b",
];
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
@@ -69,7 +68,6 @@ export const VERIFIED_OPENHANDS_MODELS = [
"devstral-medium-2507",
"devstral-small-2505",
"kimi-k2-0711-preview",
"qwen3-coder-480b",
];
// Default model for OpenHands provider
+1 -1
View File
@@ -43,7 +43,7 @@ describe("RepositorySelectionForm", () => {
});
(useCreateConversation as any).mockReturnValue({
mutate: vi.fn(() => (useIsCreatingConversation as any).mockReturnValue(true)),
mutate: vi.fn(),
isPending: false,
isSuccess: false,
});
+34 -31
View File
@@ -36,7 +36,6 @@
"react": ">=19.1.0",
"react-dom": ">=19.1.0",
"tailwind-merge": "^3.3.1",
"react": ">=19.1.0",
"tailwindcss": "^4.1.10",
},
},
@@ -168,7 +167,7 @@
"@floating-ui/dom": ["@floating-ui/dom@1.7.2", "", { "dependencies": { "@floating-ui/core": "^1.7.2", "@floating-ui/utils": "^0.2.10" } }, "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA=="],
"@floating-ui/react": ["@floating-ui/react@0.27.14", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.4", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-aSf9JXfyXpRQWMbtuW+CJQrnhzHu4Hg1Th9AkvR1o+wSW/vCUVMrtgXaRY5ToV5Fh5w3I7lXJdvlKVvYrQrppw=="],
"@floating-ui/react": ["@floating-ui/react@0.27.13", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.4", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-Qmj6t9TjgWAvbygNEu1hj4dbHI9CY0ziCMIJrmYoDIn9TUAH5lRmiIeZmRd4c6QEZkzdoH7jNnoNyoY1AIESiA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.4", "", { "dependencies": { "@floating-ui/dom": "^1.7.2" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw=="],
@@ -210,45 +209,45 @@
"@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.46.1", "", { "os": "android", "cpu": "arm" }, "sha512-oENme6QxtLCqjChRUUo3S6X8hjCXnWmJWnedD7VbGML5GUtaOtAyx+fEEXnBXVf0CBZApMQU0Idwi0FmyxzQhw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.45.1", "", { "os": "android", "cpu": "arm" }, "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.46.1", "", { "os": "android", "cpu": "arm64" }, "sha512-OikvNT3qYTl9+4qQ9Bpn6+XHM+ogtFadRLuT2EXiFQMiNkXFLQfNVppi5o28wvYdHL2s3fM0D/MZJ8UkNFZWsw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.45.1", "", { "os": "android", "cpu": "arm64" }, "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.46.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EFYNNGij2WllnzljQDQnlFTXzSJw87cpAs4TVBAWLdkvic5Uh5tISrIL6NRcxoh/b2EFBG/TK8hgRrGx94zD4A=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.45.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.46.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZaNH06O1KeTug9WI2+GRBE5Ujt9kZw4a1+OIwnBHal92I8PxSsl5KpsrPvthRynkhMck4XPdvY0z26Cym/b7oA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.45.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.46.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n4SLVebZP8uUlJ2r04+g2U/xFeiQlw09Me5UFqny8HGbARl503LNH5CqFTb5U5jNxTouhRjai6qPT0CR5c/Iig=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.45.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.46.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8vu9c02F16heTqpvo3yeiu7Vi1REDEC/yES/dIfq3tSXe6mLndiwvYr3AAvd1tMNUqE9yeGYa5w7PRbI5QUV+w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.45.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.46.1", "", { "os": "linux", "cpu": "arm" }, "sha512-K4ncpWl7sQuyp6rWiGUvb6Q18ba8mzM0rjWJ5JgYKlIXAau1db7hZnR0ldJvqKWWJDxqzSLwGUhA4jp+KqgDtQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.45.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.46.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YykPnXsjUjmXE6j6k2QBBGAn1YsJUix7pYaPLK3RVE0bQL2jfdbfykPxfF8AgBlqtYbfEnYHmLXNa6QETjdOjQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.45.1", "", { "os": "linux", "cpu": "arm" }, "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.46.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kKvqBGbZ8i9pCGW3a1FH3HNIVg49dXXTsChGFsHGXQaVJPLA4f/O+XmTxfklhccxdF5FefUn2hvkoGJH0ScWOA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.45.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.46.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-zzX5nTw1N1plmqC9RGC9vZHFuiM7ZP7oSWQGqpbmfjK7p947D518cVK1/MQudsBdcD84t6k70WNczJOct6+hdg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.45.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog=="],
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.46.1", "", { "os": "linux", "cpu": "none" }, "sha512-O8CwgSBo6ewPpktFfSDgB6SJN9XDcPSvuwxfejiddbIC/hn9Tg6Ai0f0eYDf3XvB/+PIWzOQL+7+TZoB8p9Yuw=="],
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.45.1", "", { "os": "linux", "cpu": "none" }, "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.46.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-JnCfFVEKeq6G3h3z8e60kAp8Rd7QVnWCtPm7cxx+5OtP80g/3nmPtfdCXbVl063e3KsRnGSKDHUQMydmzc/wBA=="],
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.45.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.46.1", "", { "os": "linux", "cpu": "none" }, "sha512-dVxuDqS237eQXkbYzQQfdf/njgeNw6LZuVyEdUaWwRpKHhsLI+y4H/NJV8xJGU19vnOJCVwaBFgr936FHOnJsQ=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.45.1", "", { "os": "linux", "cpu": "none" }, "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.46.1", "", { "os": "linux", "cpu": "none" }, "sha512-CvvgNl2hrZrTR9jXK1ye0Go0HQRT6ohQdDfWR47/KFKiLd5oN5T14jRdUVGF4tnsN8y9oSfMOqH6RuHh+ck8+w=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.45.1", "", { "os": "linux", "cpu": "none" }, "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.46.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-x7ANt2VOg2565oGHJ6rIuuAon+A8sfe1IeUx25IKqi49OjSr/K3awoNqr9gCwGEJo9OuXlOn+H2p1VJKx1psxA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.45.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.46.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9OADZYryz/7E8/qt0vnaHQgmia2Y0wrjSSn1V/uL+zw/i7NUhxbX4cHXdEQ7dnJgzYDS81d8+tf6nbIdRFZQoQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.45.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.46.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NuvSCbXEKY+NGWHyivzbjSVJi68Xfq1VnIvGmsuXs6TCtveeoDRKutI5vf2ntmNnVq64Q4zInet0UDQ+yMB6tA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.45.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.46.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mWz+6FSRb82xuUMMV1X3NGiaPFqbLN9aIueHleTZCc46cJvwTlvIh7reQLk4p97dv0nddyewBhwzryBHH7wtPw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.45.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.46.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-7Thzy9TMXDw9AU4f4vsLNBxh7/VOKuXi73VH3d/kHGr0tZ3x/ewgL9uC7ojUKmH1/zvmZe2tLapYcZllk3SO8Q=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.45.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.46.1", "", { "os": "win32", "cpu": "x64" }, "sha512-7GVB4luhFmGUNXXJhH2jJwZCFB3pIOixv2E3s17GQHBFUOQaISlt7aGcQgqvCaDSxTZJUzlK/QJ1FN8S94MrzQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.45.1", "", { "os": "win32", "cpu": "x64" }, "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA=="],
"@rushstack/node-core-library": ["@rushstack/node-core-library@5.14.0", "", { "dependencies": { "ajv": "~8.13.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.5.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-eRong84/rwQUlATGFW3TMTYVyqL1vfW9Lf10PH+mVGfIb9HzU3h5AASNIw+axnBLjnD0n3rT5uQBwu9fvzATrg=="],
@@ -310,9 +309,9 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.6.4", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.21", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-xDXgLjVunjHqczScfkCJ9iyjdNOVHvvCdqHSSxwM9L0l/wHkTRum67SDc020uAlCoqktJplgO2AAQeLP1wgqDQ=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.6.3", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.21", "redent": "^3.0.0" } }, "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA=="],
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
@@ -376,11 +375,11 @@
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@volar/language-core": ["@volar/language-core@2.4.22", "", { "dependencies": { "@volar/source-map": "2.4.22" } }, "sha512-gp4M7Di5KgNyIyO903wTClYBavRt6UyFNpc5LWfyZr1lBsTUY+QrVZfmbNF2aCyfklBOVk9YC4p+zkwoyT7ECg=="],
"@volar/language-core": ["@volar/language-core@2.4.20", "", { "dependencies": { "@volar/source-map": "2.4.20" } }, "sha512-dRDF1G33xaAIDqR6+mXUIjXYdu9vzSxlMGfMEwBxQsfY/JMUEXSpLTR057oTKlUQ2nIvCmP9k94A8h8z2VrNSA=="],
"@volar/source-map": ["@volar/source-map@2.4.22", "", {}, "sha512-L2nVr/1vei0xKRgO2tYVXtJYd09HTRjaZi418e85Q+QdbbqA8h7bBjfNyPPSsjnrOO4l4kaAo78c8SQUAdHvgA=="],
"@volar/source-map": ["@volar/source-map@2.4.20", "", {}, "sha512-mVjmFQH8mC+nUaVwmbxoYUy8cww+abaO8dWzqPUjilsavjxH0jCJ3Mp8HFuHsdewZs2c+SP+EO7hCd8Z92whJg=="],
"@volar/typescript": ["@volar/typescript@2.4.22", "", { "dependencies": { "@volar/language-core": "2.4.22", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-6ZczlJW1/GWTrNnkmZxJp4qyBt/SGVlcTuCWpI5zLrdPdCZsj66Aff9ZsfFaT3TyjG8zVYgBMYPuCm/eRkpcpQ=="],
"@volar/typescript": ["@volar/typescript@2.4.20", "", { "dependencies": { "@volar/language-core": "2.4.20", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Oc4DczPwQyXcVbd+5RsNEqX6ia0+w3p+klwdZQ6ZKhFjWoBP9PCPQYlKYRi/tDemWphW93P/Vv13vcE9I9D2GQ=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.18", "", { "dependencies": { "@babel/parser": "^7.28.0", "@vue/shared": "3.5.18", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw=="],
@@ -404,7 +403,7 @@
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
@@ -448,6 +447,8 @@
"chai": ["chai@5.2.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -846,7 +847,7 @@
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.46.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.46.1", "@rollup/rollup-android-arm64": "4.46.1", "@rollup/rollup-darwin-arm64": "4.46.1", "@rollup/rollup-darwin-x64": "4.46.1", "@rollup/rollup-freebsd-arm64": "4.46.1", "@rollup/rollup-freebsd-x64": "4.46.1", "@rollup/rollup-linux-arm-gnueabihf": "4.46.1", "@rollup/rollup-linux-arm-musleabihf": "4.46.1", "@rollup/rollup-linux-arm64-gnu": "4.46.1", "@rollup/rollup-linux-arm64-musl": "4.46.1", "@rollup/rollup-linux-loongarch64-gnu": "4.46.1", "@rollup/rollup-linux-ppc64-gnu": "4.46.1", "@rollup/rollup-linux-riscv64-gnu": "4.46.1", "@rollup/rollup-linux-riscv64-musl": "4.46.1", "@rollup/rollup-linux-s390x-gnu": "4.46.1", "@rollup/rollup-linux-x64-gnu": "4.46.1", "@rollup/rollup-linux-x64-musl": "4.46.1", "@rollup/rollup-win32-arm64-msvc": "4.46.1", "@rollup/rollup-win32-ia32-msvc": "4.46.1", "@rollup/rollup-win32-x64-msvc": "4.46.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ=="],
"rollup": ["rollup@4.45.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.45.1", "@rollup/rollup-android-arm64": "4.45.1", "@rollup/rollup-darwin-arm64": "4.45.1", "@rollup/rollup-darwin-x64": "4.45.1", "@rollup/rollup-freebsd-arm64": "4.45.1", "@rollup/rollup-freebsd-x64": "4.45.1", "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", "@rollup/rollup-linux-arm-musleabihf": "4.45.1", "@rollup/rollup-linux-arm64-gnu": "4.45.1", "@rollup/rollup-linux-arm64-musl": "4.45.1", "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-musl": "4.45.1", "@rollup/rollup-linux-s390x-gnu": "4.45.1", "@rollup/rollup-linux-x64-gnu": "4.45.1", "@rollup/rollup-linux-x64-musl": "4.45.1", "@rollup/rollup-win32-arm64-msvc": "4.45.1", "@rollup/rollup-win32-ia32-msvc": "4.45.1", "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
@@ -1040,6 +1041,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
"@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
@@ -1058,6 +1061,8 @@
"pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -1074,8 +1079,6 @@
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+169 -3
View File
@@ -1,4 +1,170 @@
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap");
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Outfit:wght@100..900&display=swap");
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Outfit:wght@100..900&display=swap');
@import "tailwindcss";
@import "./tokens.css";
@plugin 'tailwind-scrollbar' {
nocompatible: true;
preferredStrategy: 'pseudoelements';
}
@theme {
/* COLOR VARIABLES */
--color-primary-15: #FFFCF0;
--color-primary-30: #FFF9E1;
--color-primary-50: #FFF7D7;
--color-primary-100: #FFF3C0;
--color-primary-200: #FFEEAA;
--color-primary-300: #FFEA92;
--color-primary-400: #FFE57B;
--color-primary-500: #FFE165;
--color-primary-600: #DCC257;
--color-primary-700: #BBA54A;
--color-primary-800: #99873D;
--color-primary-900: #76682F;
--color-primary-950: #534921;
--color-primary-970: #433B1B;
--color-primary-985: #2D2812;
/* Light Neutral */
--color-light-neutral-15: #F7F8FB;
--color-light-neutral-30: #F0F2F7;
--color-light-neutral-50: #EBEDF3;
--color-light-neutral-100: #DFE2ED;
--color-light-neutral-200: #D4D8E7;
--color-light-neutral-300: #C8CDE0;
--color-light-neutral-400: #BCC3D9;
--color-light-neutral-500: #B1B9D3;
--color-light-neutral-600: #99A0B6;
--color-light-neutral-700: #82889B;
--color-light-neutral-800: #6A6F7F;
--color-light-neutral-900: #525662;
--color-light-neutral-950: #3A3C45;
--color-light-neutral-970: #2F3137;
--color-light-neutral-985: #1F2125;
/* Grey */
--color-grey-15: #EDEDEF;
--color-grey-30: #DCDDDF;
--color-grey-50: #CFD0D3;
--color-grey-100: #B5B6BA;
--color-grey-200: #9A9CA2;
--color-grey-300: #7E8088;
--color-grey-400: #63666F;
--color-grey-500: #494C57;
--color-grey-600: #3F424B;
--color-grey-700: #363840;
--color-grey-800: #2C2E34;
--color-grey-900: #222328;
--color-grey-950: #18191C;
--color-grey-970: #131417;
--color-grey-985: #0D0D0F;
/* Green */
--color-green-15: #F8FFF4;
--color-green-30: #F2FFE9;
--color-green-50: #EDFFE1;
--color-green-100: #E4FFD0;
--color-green-200: #DAFFBF;
--color-green-300: #CFFFAD;
--color-green-400: #C6FF9D;
--color-green-500: #BCFF8C;
--color-green-600: #A2DC79;
--color-green-700: #8ABB67;
--color-green-800: #719954;
--color-green-900: #577641;
--color-green-950: #3D532E;
--color-green-970: #314325;
--color-green-985: #212D19;
/* Aqua */
--color-aqua-15: #F4FFFE;
--color-aqua-30: #E9FFFE;
--color-aqua-50: #E1FFFD;
--color-aqua-100: #D1FFFD;
--color-aqua-200: #C0FFFC;
--color-aqua-300: #AEFFFB;
--color-aqua-400: #9EFFFA;
--color-aqua-500: #8DFFF9;
--color-aqua-600: #7ADCD7;
--color-aqua-700: #67BBB7
--color-aqua-800: #559995;
--color-aqua-900: #417673;
--color-aqua-950: #2E5351;
--color-aqua-970: #254341;
--color-aqua-985: #192D2C;
/* Red */
--color-red-15: #FFF0EE;
--color-red-30: #FFE2DD;
--color-red-50: #FFD7D0;
--color-red-100: #FFC1B7;
--color-red-200: #FFAC9D;
--color-red-300: #FF9481;
--color-red-400: #FF7E68;
--color-red-500: #FF684E;
--color-red-600: #DC5A43;
--color-red-700: #BB4C39;
--color-red-800: #993E2F;
--color-red-900: #763024;
--color-red-950: #532219;
--color-red-970: #431B14;
--color-red-985: #2D120E;
/* OpacityBlue */
--color-blue: #DCE5FF;
/* TYPOGRAPHY VARIABLES */
--font-size-xxs: 0.75rem; /* 12px */
--font-size-xs: 0.875rem; /* 14px */
--font-size-s: 1rem; /* 16px */
--font-size-m: 1.125rem; /* 18px */
--font-size-l: 1.5rem; /* 24px */
--font-size-xl: 2rem; /* 32px */
--font-size-xxl: 2.25rem; /* 36px */
--font-size-xxxl: 3rem; /* 48px */
}
@layer utilities {
.tg-family-outfit {
font-family: Outfit;
}
.tg-family-ibm-plex {
font-family: IBM Plex Mono
}
.tg-xxs {
font-size: var(--font-size-xxs);
}
.tg-xs {
font-size: var(--font-size-xs);
}
.tg-s {
font-size: var(--font-size-s);
}
.tg-m {
font-size: var(--font-size-m);
}
.tg-lg {
font-size: var(--font-size-l);
}
.tg-xl {
font-size: var(--font-size-xl);
}
.tg-xxl {
font-size: var(--font-size-xxl);
}
.tg-xxxl {
font-size: var(--font-size-xxxl);
}
}
+3
View File
@@ -16,3 +16,6 @@ export { Toggle } from "./components/toggle/Toggle";
export { Tabs } from "./components/tabs/Tabs";
export { Tooltip } from "./components/tooltip/Tooltip";
export { Typography } from "./components/typography/Typography";
// Styles
import "./index.css";
+2 -4
View File
@@ -14,7 +14,7 @@
"email": "stephan@all-hands.dev"
}
],
"version": "1.0.0-beta.9",
"version": "1.0.0-beta.8",
"description": "OpenHands UI Components",
"keywords": [
"openhands",
@@ -74,9 +74,7 @@
"react": ">=19.1.0",
"react-dom": ">=19.1.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"tailwind-scrollbar": "^4.0.2"
"tailwindcss": "^4.1.10"
},
"dependencies": {
"@floating-ui/react": "^0.27.12",
-175
View File
@@ -1,175 +0,0 @@
@theme {
/* COLOR VARIABLES */
--color-primary-15: #FFFCF0;
--color-primary-30: #FFF9E1;
--color-primary-50: #FFF7D7;
--color-primary-100: #FFF3C0;
--color-primary-200: #FFEEAA;
--color-primary-300: #FFEA92;
--color-primary-400: #FFE57B;
--color-primary-500: #FFE165;
--color-primary-600: #DCC257;
--color-primary-700: #BBA54A;
--color-primary-800: #99873D;
--color-primary-900: #76682F;
--color-primary-950: #534921;
--color-primary-970: #433B1B;
--color-primary-985: #2D2812;
/* Light Neutral */
--color-light-neutral-15: #F7F8FB;
--color-light-neutral-30: #F0F2F7;
--color-light-neutral-50: #EBEDF3;
--color-light-neutral-100: #DFE2ED;
--color-light-neutral-200: #D4D8E7;
--color-light-neutral-300: #C8CDE0;
--color-light-neutral-400: #BCC3D9;
--color-light-neutral-500: #B1B9D3;
--color-light-neutral-600: #99A0B6;
--color-light-neutral-700: #82889B;
--color-light-neutral-800: #6A6F7F;
--color-light-neutral-900: #525662;
--color-light-neutral-950: #3A3C45;
--color-light-neutral-970: #2F3137;
--color-light-neutral-985: #1F2125;
/* Grey */
--color-grey-15: #EDEDEF;
--color-grey-30: #DCDDDF;
--color-grey-50: #CFD0D3;
--color-grey-100: #B5B6BA;
--color-grey-200: #9A9CA2;
--color-grey-300: #7E8088;
--color-grey-400: #63666F;
--color-grey-500: #494C57;
--color-grey-600: #3F424B;
--color-grey-700: #363840;
--color-grey-800: #2C2E34;
--color-grey-900: #222328;
--color-grey-950: #18191C;
--color-grey-970: #131417;
--color-grey-985: #0D0D0F;
/* Green */
--color-green-15: #F8FFF4;
--color-green-30: #F2FFE9;
--color-green-50: #EDFFE1;
--color-green-100: #E4FFD0;
--color-green-200: #DAFFBF;
--color-green-300: #CFFFAD;
--color-green-400: #C6FF9D;
--color-green-500: #BCFF8C;
--color-green-600: #A2DC79;
--color-green-700: #8ABB67;
--color-green-800: #719954;
--color-green-900: #577641;
--color-green-950: #3D532E;
--color-green-970: #314325;
--color-green-985: #212D19;
/* Aqua */
--color-aqua-15: #F4FFFE;
--color-aqua-30: #E9FFFE;
--color-aqua-50: #E1FFFD;
--color-aqua-100: #D1FFFD;
--color-aqua-200: #C0FFFC;
--color-aqua-300: #AEFFFB;
--color-aqua-400: #9EFFFA;
--color-aqua-500: #8DFFF9;
--color-aqua-600: #7ADCD7;
--color-aqua-700: #67BBB7;
--color-aqua-800: #559995;
--color-aqua-900: #417673;
--color-aqua-950: #2E5351;
--color-aqua-970: #254341;
--color-aqua-985: #192D2C;
/* Red */
--color-red-15: #FFF0EE;
--color-red-30: #FFE2DD;
--color-red-50: #FFD7D0;
--color-red-100: #FFC1B7;
--color-red-200: #FFAC9D;
--color-red-300: #FF9481;
--color-red-400: #FF7E68;
--color-red-500: #FF684E;
--color-red-600: #DC5A43;
--color-red-700: #BB4C39;
--color-red-800: #993E2F;
--color-red-900: #763024;
--color-red-950: #532219;
--color-red-970: #431B14;
--color-red-985: #2D120E;
/* OpacityBlue */
--color-blue: #DCE5FF;
/* TYPOGRAPHY VARIABLES */
--font-size-xxs: 0.75rem; /* 12px */
--font-size-xs: 0.875rem; /* 14px */
--font-size-s: 1rem; /* 16px */
--font-size-m: 1.125rem; /* 18px */
--font-size-l: 1.5rem; /* 24px */
--font-size-xl: 2rem; /* 32px */
--font-size-xxl: 2.25rem; /* 36px */
--font-size-xxxl: 3rem; /* 48px */
/* OLD TAILWIND STYLES */
--color-primary: #C9B974;
--color-logo: #CFB755;
--color-base: #0D0F11;
--color-base-secondary: #24272E;
--color-danger: #E76A5E;
--color-success: #A5E75E;
--color-basic: #9099AC;
--color-tertiary: #454545;
--color-tertiary-light: #B7BDC2;
--color-content: #ECEDEE;
--color-content-2: #F9FBFE;
}
@layer utilities {
.tg-family-outfit {
font-family: Outfit;
}
.tg-family-ibm-plex {
font-family: IBM Plex Mono
}
.tg-xxs {
font-size: var(--font-size-xxs);
}
.tg-xs {
font-size: var(--font-size-xs);
}
.tg-s {
font-size: var(--font-size-s);
}
.tg-m {
font-size: var(--font-size-m);
}
.tg-lg {
font-size: var(--font-size-l);
}
.tg-xl {
font-size: var(--font-size-xl);
}
.tg-xxl {
font-size: var(--font-size-xxl);
}
.tg-xxxl {
font-size: var(--font-size-xxxl);
}
}
+2 -5
View File
@@ -15,10 +15,7 @@ export default defineConfig({
],
build: {
lib: {
entry: {
index: resolve(__dirname, "index.ts"),
tokens: resolve(__dirname, "tokens.css"),
},
entry: resolve(__dirname, "index.ts"),
name: "OpenHandsUI",
formats: ["es"],
fileName: "index",
@@ -32,6 +29,6 @@ export default defineConfig({
},
},
},
cssCodeSplit: true,
cssCodeSplit: false, // Bundle all CSS into a single index.css file
},
});
+4 -11
View File
@@ -1,5 +1,4 @@
import os
from pathlib import Path
__package_name__ = 'openhands_ai'
@@ -8,16 +7,10 @@ def get_version():
# Try getting the version from pyproject.toml
try:
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
candidate_paths = [
Path(root_dir) / 'pyproject.toml',
Path(root_dir) / 'openhands' / 'pyproject.toml',
]
for file_path in candidate_paths:
if file_path.is_file():
with open(file_path, 'r') as f:
for line in f:
if line.strip().startswith('version ='):
return line.split('=', 1)[1].strip().strip('"').strip("'")
with open(os.path.join(root_dir, 'pyproject.toml'), 'r') as f:
for line in f:
if line.startswith('version ='):
return line.split('=')[1].strip().strip('"')
except FileNotFoundError:
pass
@@ -193,11 +193,7 @@ class CodeActAgent(Agent):
'messages': self.llm.format_messages_for_llm(messages),
}
params['tools'] = check_tools(self.tools, self.llm.config)
params['extra_body'] = {
'metadata': state.to_llm_metadata(
model_name=self.llm.config.model, agent_name=self.name
)
}
params['extra_body'] = {'metadata': state.to_llm_metadata(agent_name=self.name)}
response = self.llm.completion(**params)
logger.debug(f'Response from LLM: {response}')
actions = self.response_to_actions(response)
@@ -123,6 +123,7 @@ def response_to_actions(
elif tool_call.function.name == FinishTool['function']['name']:
action = AgentFinishAction(
final_thought=arguments.get('message', ''),
task_completed=arguments.get('task_completed', None),
)
# ================================================
@@ -31,7 +31,7 @@ Your primary role is to assist users by executing commands, modifying code, and
</CODE_QUALITY>
<VERSION_CONTROL>
* When committing changes, you MUST use the `--author` flag to set the author to `"openhands <openhands@all-hands.dev>"`. For example: `git commit --author="openhands <openhands@all-hands.dev>" -m "Fix bug"`. This ensures all commits are attributed to the OpenHands agent, regardless of the local git config.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
@@ -25,7 +25,7 @@ Your primary role is to assist users by executing commands, modifying code, and
</CODE_QUALITY>
<VERSION_CONTROL>
* When committing changes, you MUST use the `--author` flag to set the author to `"openhands <openhands@all-hands.dev>"`. For example: `git commit --author="openhands <openhands@all-hands.dev>" -m "Fix bug"`. This ensures all commits are attributed to the OpenHands agent, regardless of the local git config.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
@@ -25,7 +25,7 @@ Your primary role is to assist users by executing commands, modifying code, and
</CODE_QUALITY>
<VERSION_CONTROL>
* When committing changes, you MUST use the `--author` flag to set the author to `"openhands <openhands@all-hands.dev>"`. For example: `git commit --author="openhands <openhands@all-hands.dev>" -m "Fix bug"`. This ensures all commits are attributed to the OpenHands agent, regardless of the local git config.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
@@ -13,6 +13,8 @@ The message should include:
- Any next steps for the user
- Explanation if you're unable to complete the task
- Any follow-up questions if more information is needed
The task_completed field should be set to True if you believed you have completed the task, and False otherwise.
"""
FinishTool = ChatCompletionToolParam(
@@ -22,12 +24,17 @@ FinishTool = ChatCompletionToolParam(
description=_FINISH_DESCRIPTION,
parameters={
'type': 'object',
'required': ['message'],
'required': ['message', 'task_completed'],
'properties': {
'message': {
'type': 'string',
'description': 'Final message to send to the user',
},
'task_completed': {
'type': 'string',
'enum': ['true', 'false', 'partial'],
'description': 'Whether you have completed the task.',
},
},
},
),
@@ -81,6 +81,7 @@ def response_to_actions(
elif tool_call.function.name == FinishTool['function']['name']:
action = AgentFinishAction(
final_thought=arguments.get('message', ''),
task_completed=arguments.get('task_completed', None),
)
else:
raise FunctionCallNotExistsError(
@@ -141,6 +141,7 @@ def response_to_actions(
if tool_call.function.name == FinishTool['function']['name']:
action = AgentFinishAction(
final_thought=arguments.get('message', ''),
task_completed=arguments.get('task_completed', None),
)
# ================================================
+2 -6
View File
@@ -15,7 +15,6 @@ from openhands.cli.settings import (
display_settings,
modify_llm_settings_advanced,
modify_llm_settings_basic,
modify_search_api_settings,
)
from openhands.cli.tui import (
COLOR_GREY,
@@ -272,9 +271,8 @@ async def handle_settings_command(
config,
'\nWhich settings would you like to modify?',
[
'LLM (Basic)',
'LLM (Advanced)',
'Search API (Optional)',
'Basic',
'Advanced',
'Go back',
],
)
@@ -283,8 +281,6 @@ async def handle_settings_command(
await modify_llm_settings_basic(config, settings_store)
elif modify_settings == 1:
await modify_llm_settings_advanced(config, settings_store)
elif modify_settings == 2:
await modify_search_api_settings(config, settings_store)
# FIXME: Currently there's an issue with the actual 'resume' behavior.
+6 -53
View File
@@ -352,23 +352,10 @@ async def run_session(
if initial_state.last_error:
# If the last session ended in an error, provide a message.
error_message = initial_state.last_error
# Check if it's an authentication error
if 'ERROR_LLM_AUTHENTICATION' in error_message:
# Start with base authentication error message
initial_message = 'Authentication error with the LLM provider. Please check your API key.'
# Add OpenHands-specific guidance if using an OpenHands model
llm_config = config.get_llm_config()
if llm_config.model.startswith('openhands/'):
initial_message += " If you're using OpenHands models, get a new API key from https://app.all-hands.dev/settings/api-keys"
else:
# For other errors, use the standard message
initial_message = (
'NOTE: the last session ended with an error.'
"Let's get back on track. Do NOT resume your task. Ask me about it."
)
initial_message = (
'NOTE: the last session ended with an error.'
"Let's get back on track. Do NOT resume your task. Ask me about it."
)
else:
# If we are resuming, we already have a task
initial_message = ''
@@ -417,19 +404,6 @@ async def run_setup_flow(config: OpenHandsConfig, settings_store: FileSettingsSt
# Use the existing settings modification function for basic setup
await modify_llm_settings_basic(config, settings_store)
# Ask if user wants to configure search API settings
print_formatted_text('')
setup_search = cli_confirm(
config,
'Would you like to configure Search API settings (optional)?',
['Yes', 'No'],
)
if setup_search == 0: # Yes
from openhands.cli.settings import modify_search_api_settings
await modify_search_api_settings(config, settings_store)
def run_alias_setup_flow(config: OpenHandsConfig) -> None:
"""Run the alias setup flow to configure shell aliases.
@@ -571,30 +545,14 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
# Use settings from settings store if available and override with command line arguments
if settings:
# Handle agent configuration
if args.agent_cls:
config.default_agent = str(args.agent_cls)
else:
# settings.agent is not None because we check for it in setup_config_from_args
assert settings.agent is not None
config.default_agent = settings.agent
# Handle LLM configuration with proper precedence:
# 1. CLI parameters (-l) have highest precedence (already handled in setup_config_from_args)
# 2. config.toml in current directory has next highest precedence (already loaded)
# 3. ~/.openhands/settings.json has lowest precedence (handled here)
# Only apply settings from settings.json if:
# - No LLM config was specified via CLI arguments (-l)
# - The current LLM config doesn't have model or API key set (indicating it wasn't loaded from config.toml)
llm_config = config.get_llm_config()
if (
not args.llm_config
and (not llm_config.model or not llm_config.api_key)
and settings.llm_model
and settings.llm_api_key
):
logger.debug('Using LLM configuration from settings.json')
if not args.llm_config and settings.llm_model and settings.llm_api_key:
llm_config = config.get_llm_config()
llm_config.model = settings.llm_model
llm_config.api_key = settings.llm_api_key
llm_config.base_url = settings.llm_base_url
@@ -603,11 +561,6 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
settings.confirmation_mode if settings.confirmation_mode else False
)
# Load search API key from settings if available and not already set from config.toml
if settings.search_api_key and not config.search_api_key:
config.search_api_key = settings.search_api_key
logger.debug('Using search API key from settings.json')
if settings.enable_default_condenser:
# TODO: Make this generic?
llm_config = config.get_llm_config()
+3 -80
View File
@@ -74,10 +74,6 @@ def display_settings(config: OpenHandsConfig) -> None:
' Memory Condensation',
'Enabled' if config.enable_default_condenser else 'Disabled',
),
(
' Search API Key',
'********' if config.search_api_key else 'Not Set',
),
(
' Configuration File',
str(Path(config.file_store_path) / 'settings.json'),
@@ -272,15 +268,14 @@ async def modify_llm_settings_basic(
# For OpenHands provider, directly show all verified models without the "use default" option
if provider == 'openhands':
print_formatted_text(HTML('\n<grey>Available OpenHands models:</grey>'))
# Create a list of models for the cli_confirm function
model_choices = VERIFIED_OPENHANDS_MODELS
model_choice = cli_confirm(
config,
(
'(Step 2/3) Select Available OpenHands Model:\n'
+ 'LLM usage is billed at the providers rates with no markup. Details: https://docs.all-hands.dev/usage/llms/openhands-llms'
),
'(Step 2/3) Select LLM Model:',
model_choices,
)
@@ -492,75 +487,3 @@ async def modify_llm_settings_advanced(
settings.enable_default_condenser = enable_memory_condensation
await settings_store.store(settings)
async def modify_search_api_settings(
config: OpenHandsConfig, settings_store: FileSettingsStore
) -> None:
"""Modify search API settings."""
session = PromptSession(key_bindings=kb_cancel())
search_api_key = None
try:
print_formatted_text(
HTML(
'\n<grey>Configure Search API Key for enhanced search capabilities.</grey>'
)
)
print_formatted_text(
HTML('<grey>You can get a Tavily API key from: https://tavily.com/</grey>')
)
print_formatted_text('')
# Show current status
current_key_status = '********' if config.search_api_key else 'Not Set'
print_formatted_text(
HTML(
f'<grey>Current Search API Key: </grey><green>{current_key_status}</green>'
)
)
print_formatted_text('')
# Ask if user wants to modify
modify_key = cli_confirm(
config,
'Do you want to modify the Search API Key?',
['Set/Update API Key', 'Remove API Key', 'Keep current setting'],
)
if modify_key == 0: # Set/Update API Key
search_api_key = await get_validated_input(
session,
'Enter Tavily Search API Key. You can get it from https://www.tavily.com/ (starts with tvly-, CTRL-c to cancel): ',
validator=lambda x: x.startswith('tvly-') if x.strip() else False,
error_message='Search API Key must start with "tvly-"',
)
elif modify_key == 1: # Remove API Key
search_api_key = '' # Empty string to remove the key
else: # Keep current setting
return
except (
UserCancelledError,
KeyboardInterrupt,
EOFError,
):
return # Return on exception
save_settings = save_settings_confirmation(config)
if not save_settings:
return
# Update config
config.search_api_key = SecretStr(search_api_key) if search_api_key else None
# Update settings store
settings = await settings_store.load()
if not settings:
settings = Settings()
settings.search_api_key = SecretStr(search_api_key) if search_api_key else None
await settings_store.store(settings)
-1
View File
@@ -190,7 +190,6 @@ VERIFIED_OPENHANDS_MODELS = [
'o4-mini',
'gemini-2.5-pro',
'kimi-k2-0711-preview',
'qwen3-coder-480b',
]
-1
View File
@@ -657,7 +657,6 @@ class AgentController:
# Take a snapshot of the current metrics before starting the delegate
state = State(
session_id=self.id.removesuffix('-delegate'),
user_id=self.user_id,
inputs=action.inputs or {},
iteration_flag=self.state.iteration_flag,
budget_flag=self.state.budget_flag,
+2 -6
View File
@@ -79,7 +79,6 @@ class State:
"""
session_id: str = ''
user_id: str | None = None
iteration_flag: IterationControlFlag = field(
default_factory=lambda: IterationControlFlag(
limit_increase_amount=100, current_value=0, max_value=100
@@ -266,19 +265,16 @@ class State:
return event
return None
def to_llm_metadata(self, model_name: str, agent_name: str) -> dict:
metadata = {
def to_llm_metadata(self, agent_name: str) -> dict:
return {
'session_id': self.session_id,
'trace_version': openhands.__version__,
'trace_user_id': self.user_id,
'tags': [
f'model:{model_name}',
f'agent:{agent_name}',
f'web_host:{os.environ.get("WEB_HOST", "unspecified")}',
f'openhands_version:{openhands.__version__}',
],
}
return metadata
def get_local_step(self):
if not self.parent_iteration:
@@ -73,7 +73,6 @@ class StateTracker:
if state is None:
self.state = State(
session_id=id.removesuffix('-delegate'),
user_id=self.user_id,
inputs={},
iteration_flag=IterationControlFlag(
limit_increase_amount=max_iterations,
+2 -10
View File
@@ -43,7 +43,7 @@ class LLMConfig(BaseModel):
log_completions_folder: The folder to log LLM completions to. Required if log_completions is True.
custom_tokenizer: A custom tokenizer to use for token counting.
native_tool_calling: Whether to use native tool calling if supported by the model. Can be True, False, or not set.
reasoning_effort: The effort to put into reasoning. This is a string that can be one of 'low', 'medium', 'high', or 'none'. Can apply to all reasoning models.
reasoning_effort: The effort to put into reasoning. This is a string that can be one of 'low', 'medium', 'high', or 'none'. Exclusive for o1 models.
seed: The seed to use for the LLM.
safety_settings: Safety settings for models that support them (like Mistral AI and Gemini).
"""
@@ -85,7 +85,7 @@ class LLMConfig(BaseModel):
log_completions_folder: str = Field(default=os.path.join(LOG_DIR, 'completions'))
custom_tokenizer: str | None = Field(default=None)
native_tool_calling: bool | None = Field(default=None)
reasoning_effort: str | None = Field(default=None)
reasoning_effort: str | None = Field(default='high')
seed: int | None = Field(default=None)
safety_settings: list[dict[str, str]] | None = Field(
default=None,
@@ -171,14 +171,6 @@ class LLMConfig(BaseModel):
if self.openrouter_app_name:
os.environ['OR_APP_NAME'] = self.openrouter_app_name
# Set reasoning_effort to 'high' by default for non-Gemini models
# Gemini models use optimized thinking budget when reasoning_effort is None
logger.debug(
f'Setting reasoning_effort for model {self.model} with reasoning_effort {self.reasoning_effort}'
)
if self.reasoning_effort is None and 'gemini-2.5-pro' not in self.model:
self.reasoning_effort = 'high'
# Set an API version by default for Azure models
# Required for newer models.
# Azure issue: https://github.com/All-Hands-AI/OpenHands/issues/7755
+6 -48
View File
@@ -337,7 +337,7 @@ def finalize_config(cfg: OpenHandsConfig) -> None:
if cfg.workspace_base is not None or cfg.workspace_mount_path is not None:
logger.openhands_logger.warning(
'DEPRECATED: The WORKSPACE_BASE and WORKSPACE_MOUNT_PATH environment variables are deprecated. '
"Please use SANDBOX_VOLUMES instead, e.g. 'SANDBOX_VOLUMES=/my/host/dir:/workspace:rw'"
"Please use RUNTIME_MOUNT instead, e.g. 'RUNTIME_MOUNT=/my/host/dir:/workspace:rw'"
)
if cfg.sandbox.volumes is not None:
# Split by commas to handle multiple mounts
@@ -515,14 +515,7 @@ def get_llm_config_arg(
if llm_config_arg.startswith('llm.'):
llm_config_arg = llm_config_arg[4:]
logger.openhands_logger.debug(
f'Loading llm config "{llm_config_arg}" from {toml_file}'
)
# Check if the file exists
if not os.path.exists(toml_file):
logger.openhands_logger.debug(f'Config file not found: {toml_file}')
return None
logger.openhands_logger.debug(f'Loading llm config from {llm_config_arg}')
# load the toml file
try:
@@ -540,10 +533,7 @@ def get_llm_config_arg(
# update the llm config with the specified section
if 'llm' in toml_config and llm_config_arg in toml_config['llm']:
return LLMConfig(**toml_config['llm'][llm_config_arg])
logger.openhands_logger.debug(
f'LLM config "{llm_config_arg}" not found in {toml_file}'
)
logger.openhands_logger.debug(f'Loading from toml failed for {llm_config_arg}')
return None
@@ -851,52 +841,20 @@ def setup_config_from_args(args: argparse.Namespace) -> OpenHandsConfig:
"""Load config from toml and override with command line arguments.
Common setup used by both CLI and main.py entry points.
Configuration precedence (from highest to lowest):
1. CLI parameters (e.g., -l for LLM config)
2. config.toml in current directory (or --config-file location if specified)
3. ~/.openhands/settings.json and ~/.openhands/config.toml
"""
# Load base config from toml and env vars
config = load_openhands_config(config_file=args.config_file)
# Override with command line arguments if provided
if args.llm_config:
logger.openhands_logger.debug(f'CLI specified LLM config: {args.llm_config}')
# Check if the LLM config is NOT in the loaded configs
# if we didn't already load it, get it from the toml file
if args.llm_config not in config.llms:
# Try to load from the specified config file
llm_config = get_llm_config_arg(args.llm_config, args.config_file)
# If not found in the specified config file, try the user's config.toml
if llm_config is None and args.config_file != os.path.join(
os.path.expanduser('~'), '.openhands', 'config.toml'
):
user_config = os.path.join(
os.path.expanduser('~'), '.openhands', 'config.toml'
)
if os.path.exists(user_config):
logger.openhands_logger.debug(
f"Trying to load LLM config '{args.llm_config}' from user config: {user_config}"
)
llm_config = get_llm_config_arg(args.llm_config, user_config)
llm_config = get_llm_config_arg(args.llm_config)
else:
# If it's already in the loaded configs, use that
llm_config = config.llms[args.llm_config]
logger.openhands_logger.debug(
f"Using LLM config '{args.llm_config}' from loaded configuration"
)
if llm_config is None:
raise ValueError(
f"Cannot find LLM configuration '{args.llm_config}' in any config file"
)
# Set this as the default LLM config (highest precedence)
raise ValueError(f'Invalid toml file, cannot read {args.llm_config}')
config.set_llm_config(llm_config)
logger.openhands_logger.debug(
f'Set LLM config from CLI parameter: {args.llm_config}'
)
# Override default agent if provided
if args.agent_cls:
+19
View File
@@ -15,6 +15,12 @@ from openhands.core.config import (
setup_config_from_args,
)
from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl
from openhands.core.exceptions import (
AgentRuntimeDisconnectedError,
AgentRuntimeNotFoundError,
AgentRuntimeTimeoutError,
AgentRuntimeUnavailableError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.loop import run_agent_until_done
from openhands.core.schema import AgentState
@@ -207,6 +213,19 @@ async def run_controller(
await run_agent_until_done(controller, runtime, memory, end_states)
except Exception as e:
logger.error(f'Exception in main loop: {e}')
# Set the error in the state so it can be detected by is_fatal_evaluation_error
controller.state.last_error = f'{type(e).__name__}: {str(e)}'
# If it's a fatal runtime error, we should re-raise it so the evaluation loop can handle it
if isinstance(
e,
(
AgentRuntimeTimeoutError,
AgentRuntimeDisconnectedError,
AgentRuntimeUnavailableError,
AgentRuntimeNotFoundError,
),
):
raise e
# save session when we're about to close
if config.file_store is not None and config.file_store != 'memory':
+9
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from openhands.core.schema import ActionType
@@ -19,18 +20,26 @@ class ChangeAgentStateAction(Action):
return f'Agent state changed to {self.agent_state}'
class AgentFinishTaskCompleted(Enum):
FALSE = 'false'
PARTIAL = 'partial'
TRUE = 'true'
@dataclass
class AgentFinishAction(Action):
"""An action where the agent finishes the task.
Attributes:
final_thought (str): The message to send to the user.
task_completed (enum): Whether the agent believes the task has been completed.
outputs (dict): The other outputs of the agent, for instance "content".
thought (str): The agent's explanation of its actions.
action (str): The action type, namely ActionType.FINISH.
"""
final_thought: str = ''
task_completed: AgentFinishTaskCompleted | None = None
outputs: dict[str, Any] = field(default_factory=dict)
thought: str = ''
action: str = ActionType.FINISH
-4
View File
@@ -56,10 +56,6 @@ def handle_action_deprecated_args(args: dict[str, Any]) -> dict[str, Any]:
if 'keep_prompt' in args:
args.pop('keep_prompt')
# task_completed has been deprecated - remove it from args to maintain backward compatibility
if 'task_completed' in args:
args.pop('task_completed')
# Handle translated_ipython_code deprecation
if 'translated_ipython_code' in args:
code = args.pop('translated_ipython_code')
-3
View File
@@ -121,9 +121,6 @@ def event_to_dict(event: 'Event') -> dict:
props.pop(key, None)
if 'security_risk' in props and props['security_risk'] is None:
props.pop('security_risk')
# Remove task_completed from serialization when it's None (backward compatibility)
if 'task_completed' in props and props['task_completed'] is None:
props.pop('task_completed')
if 'action' in d:
d['args'] = props
if event.timeout is not None:
@@ -1,7 +1,5 @@
import os
from openhands.core.config.agent_config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.utils.import_utils import get_impl
@@ -13,15 +11,6 @@ class ExperimentManager:
) -> ConversationInitData:
return conversation_settings
@staticmethod
def run_agent_config_variant_test(
user_id: str, conversation_id: str, agent_config: AgentConfig
) -> AgentConfig:
logger.debug(
f'Running agent config variant test for user_id={user_id}, conversation_id={conversation_id}'
)
return agent_config
experiment_manager_cls = os.environ.get(
'OPENHANDS_EXPERIMENT_MANAGER_CLS',
@@ -5,7 +5,6 @@ from typing import Any
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import (
BaseGitService,
Branch,
@@ -14,11 +13,9 @@ from openhands.integrations.service_types import (
ProviderType,
Repository,
RequestMethod,
ResourceNotFoundError,
SuggestedTask,
User,
)
from openhands.microagent.types import MicroagentContentResponse
from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
@@ -67,50 +64,6 @@ class BitBucketService(BaseGitService, GitService):
"""Get latest working token of the user."""
return self.token
async def _get_cursorrules_url(self, repository: str) -> str:
"""Get the URL for checking .cursorrules file."""
# Get repository details to get the main branch
repo_details = await self.get_repository_details_from_repo_name(repository)
if not repo_details.main_branch:
raise ResourceNotFoundError(
f'Main branch not found for repository {repository}. '
f'This repository may be empty or have no default branch configured.'
)
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/.cursorrules'
async def _get_microagents_directory_url(
self, repository: str, microagents_path: str
) -> str:
"""Get the URL for checking microagents directory."""
# Get repository details to get the main branch
repo_details = await self.get_repository_details_from_repo_name(repository)
if not repo_details.main_branch:
raise ResourceNotFoundError(
f'Main branch not found for repository {repository}. '
f'This repository may be empty or have no default branch configured.'
)
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{microagents_path}'
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
return None
def _is_valid_microagent_file(self, item: dict) -> bool:
"""Check if an item represents a valid microagent file."""
return (
item['type'] == 'commit_file'
and item['path'].endswith('.md')
and not item['path'].endswith('README.md')
)
def _get_file_name_from_item(self, item: dict) -> str:
"""Extract file name from directory item."""
return item['path'].split('/')[-1]
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
"""Extract file path from directory item."""
return item['path']
def _has_token_expired(self, status_code: int) -> bool:
return status_code == 401
@@ -333,8 +286,6 @@ class BitBucketService(BaseGitService, GitService):
data, _ = await self._make_request(url)
uuid = data.get('uuid', '')
main_branch = data.get('mainbranch', {}).get('name')
return Repository(
id=uuid,
full_name=f'{data.get("workspace", {}).get("slug", "")}/{data.get("slug", "")}',
@@ -347,7 +298,6 @@ class BitBucketService(BaseGitService, GitService):
if data.get('workspace', {}).get('is_private') is False
else OwnerType.USER
),
main_branch=main_branch,
)
async def get_branches(self, repository: str) -> list[Branch]:
@@ -435,41 +385,6 @@ class BitBucketService(BaseGitService, GitService):
# Return the URL to the pull request
return data.get('links', {}).get('html', {}).get('href', '')
async def get_microagent_content(
self, repository: str, file_path: str
) -> MicroagentContentResponse:
"""Fetch individual file content from Bitbucket repository.
Args:
repository: Repository name in format 'workspace/repo_slug'
file_path: Path to the file within the repository
Returns:
MicroagentContentResponse with parsed content and triggers
Raises:
RuntimeError: If file cannot be fetched or doesn't exist
"""
# Step 1: Get repository details using existing method
repo_details = await self.get_repository_details_from_repo_name(repository)
if not repo_details.main_branch:
logger.warning(
f'No main branch found in repository info for {repository}. '
f'Repository response: mainbranch field missing'
)
raise ResourceNotFoundError(
f'Main branch not found for repository {repository}. '
f'This repository may be empty or have no default branch configured.'
)
# Step 2: Get file content using the main branch
file_url = f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{file_path}'
response, _ = await self._make_request(file_url)
# Parse the content to extract triggers from frontmatter
return self._parse_microagent_content(response, file_path)
bitbucket_service_cls = os.environ.get(
'OPENHANDS_BITBUCKET_SERVICE_CLS',
@@ -1,4 +1,3 @@
import base64
import json
import os
from datetime import datetime
@@ -25,7 +24,6 @@ from openhands.integrations.service_types import (
UnknownException,
User,
)
from openhands.microagent.types import MicroagentContentResponse
from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
@@ -90,36 +88,6 @@ class GitHubService(BaseGitService, GitService):
async def get_latest_token(self) -> SecretStr | None:
return self.token
async def _get_cursorrules_url(self, repository: str) -> str:
"""Get the URL for checking .cursorrules file."""
return f'{self.BASE_URL}/repos/{repository}/contents/.cursorrules'
async def _get_microagents_directory_url(
self, repository: str, microagents_path: str
) -> str:
"""Get the URL for checking microagents directory."""
return f'{self.BASE_URL}/repos/{repository}/contents/{microagents_path}'
def _is_valid_microagent_file(self, item: dict) -> bool:
"""Check if an item represents a valid microagent file."""
return (
item['type'] == 'file'
and item['name'].endswith('.md')
and item['name'] != 'README.md'
)
def _get_file_name_from_item(self, item: dict) -> str:
"""Extract file name from directory item."""
return item['name']
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
"""Extract file path from directory item."""
return f'{microagents_path}/{item["name"]}'
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
return None
async def _make_request(
self,
url: str,
@@ -569,29 +537,6 @@ class GitHubService(BaseGitService, GitService):
# Return the HTML URL of the created PR
return response['html_url']
async def get_microagent_content(
self, repository: str, file_path: str
) -> MicroagentContentResponse:
"""Fetch individual file content from GitHub repository.
Args:
repository: Repository name in format 'owner/repo'
file_path: Path to the file within the repository
Returns:
MicroagentContentResponse with parsed content and triggers
Raises:
RuntimeError: If file cannot be fetched or doesn't exist
"""
file_url = f'{self.BASE_URL}/repos/{repository}/contents/{file_path}'
file_data, _ = await self._make_request(file_url)
file_content = base64.b64decode(file_data['content']).decode('utf-8')
# Parse the content to extract triggers from frontmatter
return self._parse_microagent_content(file_content, file_path)
github_service_cls = os.environ.get(
'OPENHANDS_GITHUB_SERVICE_CLS',
@@ -17,7 +17,6 @@ from openhands.integrations.service_types import (
UnknownException,
User,
)
from openhands.microagent.types import MicroagentContentResponse
from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
@@ -82,40 +81,6 @@ class GitLabService(BaseGitService, GitService):
async def get_latest_token(self) -> SecretStr | None:
return self.token
async def _get_cursorrules_url(self, repository: str) -> str:
"""Get the URL for checking .cursorrules file."""
project_id = self._extract_project_id(repository)
return (
f'{self.BASE_URL}/projects/{project_id}/repository/files/.cursorrules/raw'
)
async def _get_microagents_directory_url(
self, repository: str, microagents_path: str
) -> str:
"""Get the URL for checking microagents directory."""
project_id = self._extract_project_id(repository)
return f'{self.BASE_URL}/projects/{project_id}/repository/tree'
def _get_microagents_directory_params(self, microagents_path: str) -> dict:
"""Get parameters for the microagents directory request."""
return {'path': microagents_path, 'recursive': 'true'}
def _is_valid_microagent_file(self, item: dict) -> bool:
"""Check if an item represents a valid microagent file."""
return (
item['type'] == 'blob'
and item['name'].endswith('.md')
and item['name'] != 'README.md'
)
def _get_file_name_from_item(self, item: dict) -> str:
"""Extract file name from directory item."""
return item['name']
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
"""Extract file path from directory item."""
return item['path']
async def _make_request(
self,
url: str,
@@ -152,11 +117,7 @@ class GitLabService(BaseGitService, GitService):
if 'Link' in response.headers:
headers['Link'] = response.headers['Link']
content_type = response.headers.get('Content-Type', '')
if 'application/json' in content_type:
return response.json(), headers
else:
return response.text, headers
return response.json(), headers
except httpx.HTTPStatusError as e:
raise self.handle_http_status_error(e)
@@ -562,55 +523,6 @@ class GitLabService(BaseGitService, GitService):
return response['web_url']
def _extract_project_id(self, repository: str) -> str:
"""Extract project_id from repository name for GitLab API calls.
Args:
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
Returns:
URL-encoded project ID for GitLab API
"""
if '/' in repository:
parts = repository.split('/')
if len(parts) >= 3 and '.' in parts[0]:
# Self-hosted GitLab: 'domain/owner/repo' -> 'owner/repo'
project_id = '/'.join(parts[1:]).replace('/', '%2F')
else:
# Regular GitLab: 'owner/repo' -> 'owner/repo'
project_id = repository.replace('/', '%2F')
else:
project_id = repository
return project_id
async def get_microagent_content(
self, repository: str, file_path: str
) -> MicroagentContentResponse:
"""Fetch individual file content from GitLab repository.
Args:
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
file_path: Path to the file within the repository
Returns:
MicroagentContentResponse with parsed content and triggers
Raises:
RuntimeError: If file cannot be fetched or doesn't exist
"""
# Extract project_id from repository name
project_id = self._extract_project_id(repository)
encoded_file_path = file_path.replace('/', '%2F')
base_url = f'{self.BASE_URL}/projects/{project_id}'
file_url = f'{base_url}/repository/files/{encoded_file_path}/raw'
response, _ = await self._make_request(file_url)
# Parse the content to extract triggers from frontmatter
return self._parse_microagent_content(response, file_path)
gitlab_service_cls = os.environ.get(
'OPENHANDS_GITLAB_SERVICE_CLS',
-101
View File
@@ -22,14 +22,11 @@ from openhands.integrations.service_types import (
AuthenticationError,
Branch,
GitService,
MicroagentParseError,
ProviderType,
Repository,
ResourceNotFoundError,
SuggestedTask,
User,
)
from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse
from openhands.server.types import AppMode
@@ -410,104 +407,6 @@ class ProviderHandler:
return main_branches + other_branches
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
"""Get microagents from a repository using the appropriate service.
Args:
repository: Repository name in the format 'owner/repo'
Returns:
List of microagents found in the repository
Raises:
AuthenticationError: If authentication fails
"""
# Try all available providers in order
errors = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
result = await service.get_microagents(repository)
# Only return early if we got a non-empty result
if result:
return result
# If we got an empty array, continue checking other providers
logger.debug(
f'No microagents found on {provider} for {repository}, trying other providers'
)
except Exception as e:
errors.append(f'{provider.value}: {str(e)}')
logger.warning(
f'Error fetching microagents from {provider} for {repository}: {e}'
)
# If all providers failed or returned empty results, return empty array
if errors:
logger.error(
f'Failed to fetch microagents for {repository} with all available providers. Errors: {"; ".join(errors)}'
)
raise AuthenticationError(f'Unable to fetch microagents for {repository}')
# All providers returned empty arrays
return []
async def get_microagent_content(
self, repository: str, file_path: str
) -> MicroagentContentResponse:
"""Get content of a specific microagent file from a repository.
Args:
repository: Repository name in the format 'owner/repo'
file_path: Path to the microagent file within the repository
Returns:
MicroagentContentResponse with parsed content and triggers
Raises:
AuthenticationError: If authentication fails
"""
# Try all available providers in order
errors = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
result = await service.get_microagent_content(repository, file_path)
# If we got content, return it immediately
if result:
return result
# If we got empty content, continue checking other providers
logger.debug(
f'No content found on {provider} for {repository}/{file_path}, trying other providers'
)
except ResourceNotFoundError:
logger.debug(
f'File not found on {provider} for {repository}/{file_path}, trying other providers'
)
continue
except MicroagentParseError as e:
# Parsing errors are specific to the provider, add to errors list
errors.append(f'{provider.value}: {str(e)}')
logger.warning(
f'Error parsing microagent content from {provider} for {repository}: {e}'
)
except Exception as e:
# For other errors (auth, rate limit, etc.), add to errors list
errors.append(f'{provider.value}: {str(e)}')
logger.warning(
f'Error fetching microagent content from {provider} for {repository}: {e}'
)
# If all providers failed or returned empty results, raise an error
if errors:
logger.error(
f'Failed to fetch microagent content for {repository} with all available providers. Errors: {"; ".join(errors)}'
)
# All providers returned empty content or file not found
raise AuthenticationError(
f'Microagent file {file_path} not found in {repository}'
)
async def get_authenticated_git_url(self, repo_name: str) -> str:
"""Get an authenticated git URL for a repository.
+1 -248
View File
@@ -1,7 +1,5 @@
from abc import ABC, abstractmethod
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Protocol
from httpx import AsyncClient, HTTPError, HTTPStatusError
@@ -9,8 +7,6 @@ from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel, SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.microagent.microagent import BaseMicroagent
from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse
from openhands.server.types import AppMode
@@ -136,7 +132,6 @@ class Repository(BaseModel):
owner_type: OwnerType | None = (
None # Whether the repository is owned by a user or organization
)
main_branch: str | None = None # The main/default branch of the repository
class AuthenticationError(ValueError):
@@ -157,18 +152,6 @@ class RateLimitError(ValueError):
pass
class ResourceNotFoundError(ValueError):
"""Raised when a requested resource (file, directory, etc.) is not found."""
pass
class MicroagentParseError(ValueError):
"""Raised when there is an error parsing a microagent file."""
pass
class RequestMethod(Enum):
POST = 'post'
GET = 'get'
@@ -188,38 +171,6 @@ class BaseGitService(ABC):
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]: ...
@abstractmethod
async def _get_cursorrules_url(self, repository: str) -> str:
"""Get the URL for checking .cursorrules file."""
...
@abstractmethod
async def _get_microagents_directory_url(
self, repository: str, microagents_path: str
) -> str:
"""Get the URL for checking microagents directory."""
...
@abstractmethod
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
...
@abstractmethod
def _is_valid_microagent_file(self, item: dict) -> bool:
"""Check if an item represents a valid microagent file."""
...
@abstractmethod
def _get_file_name_from_item(self, item: dict) -> str:
"""Extract file name from directory item."""
...
@abstractmethod
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
"""Extract file path from directory item."""
...
async def execute_request(
self,
client: AsyncClient,
@@ -234,15 +185,9 @@ class BaseGitService(ABC):
def handle_http_status_error(
self, e: HTTPStatusError
) -> (
AuthenticationError | RateLimitError | ResourceNotFoundError | UnknownException
):
) -> AuthenticationError | RateLimitError | UnknownException:
if e.response.status_code == 401:
return AuthenticationError(f'Invalid {self.provider} token')
elif e.response.status_code == 404:
return ResourceNotFoundError(
f'Resource not found on {self.provider} API: {e}'
)
elif e.response.status_code == 429:
logger.warning(f'Rate limit exceeded on {self.provider} API: {e}')
return RateLimitError('GitHub API rate limit exceeded')
@@ -254,184 +199,6 @@ class BaseGitService(ABC):
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
def _determine_microagents_path(self, repository_name: str) -> str:
"""Determine the microagents directory path based on repository name."""
actual_repo_name = repository_name.split('/')[-1]
# Check for special repository names that use a different structure
if actual_repo_name == '.openhands' or actual_repo_name == 'openhands-config':
# For repository name ".openhands", scan "microagents" folder
return 'microagents'
else:
# Default behavior: look for .openhands/microagents directory
return '.openhands/microagents'
def _create_microagent_response(
self, file_name: str, path: str
) -> MicroagentResponse:
"""Create a microagent response from basic file information."""
# Extract name without extension
name = file_name.replace('.md', '').replace('.cursorrules', 'cursorrules')
return MicroagentResponse(
name=name,
path=path,
created_at=datetime.now(),
)
def _parse_microagent_content(
self, content: str, file_path: str
) -> MicroagentContentResponse:
"""Parse microagent content and extract triggers using BaseMicroagent.load.
Args:
content: Raw microagent file content
file_path: Path to the file (used for microagent loading)
Returns:
MicroagentContentResponse with parsed content and triggers
Raises:
MicroagentParseError: If the microagent file cannot be parsed
"""
try:
# Use BaseMicroagent.load to properly parse the content
# Create a temporary path object for the file
temp_path = Path(file_path)
# Load the microagent using the existing infrastructure
microagent = BaseMicroagent.load(path=temp_path, file_content=content)
# Extract triggers from the microagent's metadata
triggers = microagent.metadata.triggers
# Return the MicroagentContentResponse
return MicroagentContentResponse(
content=microagent.content,
path=file_path,
triggers=triggers,
git_provider=self.provider,
)
except Exception as e:
logger.error(f'Error parsing microagent content for {file_path}: {str(e)}')
raise MicroagentParseError(
f'Failed to parse microagent file {file_path}: {str(e)}'
)
async def _fetch_cursorrules_content(self, repository: str) -> Any | None:
"""Fetch .cursorrules file content from the repository via API.
Args:
repository: Repository name in format specific to the provider
Returns:
Raw API response content if .cursorrules file exists, None otherwise
"""
cursorrules_url = await self._get_cursorrules_url(repository)
cursorrules_response, _ = await self._make_request(cursorrules_url)
return cursorrules_response
async def _check_cursorrules_file(
self, repository: str
) -> MicroagentResponse | None:
"""Check for .cursorrules file in the repository and return microagent response if found.
Args:
repository: Repository name in format specific to the provider
Returns:
MicroagentResponse for .cursorrules file if found, None otherwise
"""
try:
cursorrules_content = await self._fetch_cursorrules_content(repository)
if cursorrules_content:
return self._create_microagent_response('.cursorrules', '.cursorrules')
except ResourceNotFoundError:
logger.debug(f'No .cursorrules file found in {repository}')
except Exception as e:
logger.warning(f'Error checking .cursorrules file in {repository}: {e}')
return None
async def _process_microagents_directory(
self, repository: str, microagents_path: str
) -> list[MicroagentResponse]:
"""Process microagents directory and return list of microagent responses.
Args:
repository: Repository name in format specific to the provider
microagents_path: Path to the microagents directory
Returns:
List of MicroagentResponse objects found in the directory
"""
microagents = []
try:
directory_url = await self._get_microagents_directory_url(
repository, microagents_path
)
directory_params = self._get_microagents_directory_params(microagents_path)
response, _ = await self._make_request(directory_url, directory_params)
# Handle different response structures
items = response
if isinstance(response, dict) and 'values' in response:
# Bitbucket format
items = response['values']
elif isinstance(response, dict) and 'nodes' in response:
# GraphQL format (if used)
items = response['nodes']
for item in items:
if self._is_valid_microagent_file(item):
try:
file_name = self._get_file_name_from_item(item)
file_path = self._get_file_path_from_item(
item, microagents_path
)
microagents.append(
self._create_microagent_response(file_name, file_path)
)
except Exception as e:
logger.warning(
f'Error processing microagent {item.get("name", "unknown")}: {str(e)}'
)
except ResourceNotFoundError:
logger.info(
f'No microagents directory found in {repository} at {microagents_path}'
)
except Exception as e:
logger.warning(f'Error fetching microagents directory: {str(e)}')
return microagents
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
"""Generic implementation of get_microagents that works across all providers.
Args:
repository: Repository name in format specific to the provider
Returns:
List of microagents found in the repository (without content for performance)
"""
microagents_path = self._determine_microagents_path(repository)
microagents = []
# Step 1: Check for .cursorrules file
cursorrules_microagent = await self._check_cursorrules_file(repository)
if cursorrules_microagent:
microagents.append(cursorrules_microagent)
# Step 2: Check for microagents directory and process .md files
directory_microagents = await self._process_microagents_directory(
repository, microagents_path
)
microagents.extend(directory_microagents)
return microagents
class GitService(Protocol):
"""Protocol defining the interface for Git service providers"""
@@ -481,17 +248,3 @@ class GitService(Protocol):
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository"""
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
"""Get microagents from a repository"""
...
async def get_microagent_content(
self, repository: str, file_path: str
) -> MicroagentContentResponse:
"""Get content of a specific microagent file
Returns:
MicroagentContentResponse with parsed content and triggers
"""
...
+3 -2
View File
@@ -306,11 +306,12 @@ Review the changes and make sure they are as expected. Edit the file again if ne
""",
},
'finish': {
'example': """
'task_completed': """
ASSISTANT:
The server is running on port 5000 with PID 126. You can access the list of numbers in a table format by visiting http://127.0.0.1:5000. Let me know if you have any further requests!
<function=finish>
<parameter=message>The task has been completed. The web server is running and displaying numbers 1-10 in a table format at http://127.0.0.1:5000.</parameter>
<parameter=task_completed>true</parameter>
</function>
"""
},
@@ -372,7 +373,7 @@ USER: Create a list of numbers from 1 to 10, and display them in a web page at p
example += TOOL_EXAMPLES['execute_bash']['run_server_again']
if 'finish' in available_tools:
example += TOOL_EXAMPLES['finish']['example']
example += TOOL_EXAMPLES['finish']['task_completed']
example += """
--------------------- END OF EXAMPLE ---------------------
+7 -30
View File
@@ -88,8 +88,6 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
'gpt-4.1',
'kimi-k2-0711-preview',
'kimi-k2-instruct',
'Qwen3-Coder-480B-A35B-Instruct',
'qwen3-coder', # this will match both qwen3-coder-480b (openhands provider) and qwen3-coder (for openrouter)
]
REASONING_EFFORT_SUPPORTED_MODELS = [
@@ -195,24 +193,7 @@ class LLM(RetryMixin, DebugMixin):
self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS
or self.config.model.split('/')[-1] in REASONING_EFFORT_SUPPORTED_MODELS
):
# For Gemini models, only map 'low' to optimized thinking budget
# Let other reasoning_effort values pass through to API as-is
if 'gemini-2.5-pro' in self.config.model:
logger.debug(
f'Gemini model {self.config.model} with reasoning_effort {self.config.reasoning_effort}'
)
if self.config.reasoning_effort in {None, 'low', 'none'}:
kwargs['thinking'] = {'budget_tokens': 128}
kwargs['allowed_openai_params'] = ['thinking']
kwargs.pop('reasoning_effort', None)
else:
kwargs['reasoning_effort'] = self.config.reasoning_effort
logger.debug(
f'Gemini model {self.config.model} with reasoning_effort {self.config.reasoning_effort} mapped to thinking {kwargs.get("thinking")}'
)
else:
kwargs['reasoning_effort'] = self.config.reasoning_effort
kwargs['reasoning_effort'] = self.config.reasoning_effort
kwargs.pop(
'temperature'
) # temperature is not supported for reasoning models
@@ -461,16 +442,12 @@ class LLM(RetryMixin, DebugMixin):
},
)
try:
resp_json = response.json()
if 'data' not in resp_json:
logger.info(
f'No data field in model info response from LiteLLM proxy: {resp_json}'
)
all_model_info = resp_json.get('data', [])
except Exception as e:
logger.info(f'Error parsing JSON response from LiteLLM proxy: {e}')
all_model_info = []
resp_json = response.json()
if 'data' not in resp_json:
logger.error(
f'Error getting model info from LiteLLM proxy: {resp_json}'
)
all_model_info = resp_json.get('data', [])
current_model_info = next(
(
info
+1 -21
View File
@@ -8,7 +8,6 @@ from pydantic import BaseModel
from openhands.controller.state.state import State
from openhands.core.config.condenser_config import CondenserConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.agent import CondensationAction
from openhands.memory.view import View
@@ -102,29 +101,10 @@ class Condenser(ABC):
def condensed_history(self, state: State) -> View | Condensation:
"""Condense the state's history."""
if hasattr(self, 'llm'):
model_name = self.llm.config.model
else:
model_name = 'unknown'
self._llm_metadata = state.to_llm_metadata(
model_name=model_name, agent_name='condenser'
)
self._llm_metadata = state.to_llm_metadata('condenser')
with self.metadata_batch(state):
return self.condense(state.view)
@property
def llm_metadata(self) -> dict[str, Any]:
"""Metadata to be passed to the LLM when using this condenser.
This metadata is used to provide context about the condensation process and can be used by the LLM to understand how the history was condensed.
"""
if not self._llm_metadata:
logger.warning(
'LLM metadata is empty. Ensure to set it in the condenser implementation.'
)
return self._llm_metadata
@classmethod
def register_config(cls, configuration_type: type[CondenserConfig]) -> None:
"""Register a new condenser configuration type.
@@ -133,7 +133,7 @@ CURRENT_STATE: Last flip: Heads, Haiku count: 15/20"""
response = self.llm.completion(
messages=self.llm.format_messages_for_llm(messages),
extra_body={'metadata': self.llm_metadata},
extra_body={'metadata': self._llm_metadata},
)
summary = response.choices[0].message.content
+3
View File
@@ -242,6 +242,9 @@ class ConversationMemory:
# Add the LLM message (assistant) that initiated the tool calls
# (overwrites any previous message with the same response_id)
logger.debug(
f'Tool calls type: {type(assistant_msg.tool_calls)}, value: {assistant_msg.tool_calls}'
)
pending_tool_call_action_messages[llm_response.id] = Message(
role=getattr(assistant_msg, 'role', 'assistant'),
# tool call content SHOULD BE a string
-23
View File
@@ -1,4 +1,3 @@
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
@@ -35,25 +34,3 @@ class MicroagentMetadata(BaseModel):
mcp_tools: MCPConfig | None = (
None # optional, for microagents that provide additional MCP tools
)
class MicroagentResponse(BaseModel):
"""Response model for microagents endpoint.
Note: This model only includes basic metadata that can be determined
without parsing microagent content. Use the separate content API
to get detailed microagent information.
"""
name: str
path: str
created_at: datetime
class MicroagentContentResponse(BaseModel):
"""Response model for individual microagent content endpoint."""
content: str
path: str
triggers: list[str] = []
git_provider: str | None = None
+3 -13
View File
@@ -133,8 +133,7 @@ class Runtime(FileEditRuntimeMixin):
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
):
self.git_handler = GitHandler(
execute_shell_fn=self._execute_shell_fn_git_handler,
create_file_fn=self._create_file_fn_git_handler,
execute_shell_fn=self._execute_shell_fn_git_handler
)
self.sid = sid
self.event_stream = event_stream
@@ -352,14 +351,14 @@ class Runtime(FileEditRuntimeMixin):
error_message = f'{type(e).__name__}: {str(e)}'
self.log('error', f'Unexpected error while running action: {error_message}')
self.log('error', f'Problematic action: {str(event)}')
self.set_runtime_status(runtime_status, error_message, level='error')
self.set_runtime_status(runtime_status, error_message)
return
except Exception as e:
runtime_status = RuntimeStatus.ERROR
error_message = f'{type(e).__name__}: {str(e)}'
self.log('error', f'Unexpected error while running action: {error_message}')
self.log('error', f'Problematic action: {str(event)}')
self.set_runtime_status(runtime_status, error_message, level='error')
self.set_runtime_status(runtime_status, error_message)
return
observation._cause = event.id # type: ignore[attr-defined]
@@ -1018,15 +1017,6 @@ fi
return CommandResult(content=content, exit_code=exit_code)
def _create_file_fn_git_handler(self, path: str, content: str) -> int:
"""
This function is used by the GitHandler to execute shell commands.
"""
obs = self.write(FileWriteAction(path=path, content=content))
if isinstance(obs, ErrorObservation):
return -1
return 0
def get_git_changes(self, cwd: str) -> list[dict[str, str]] | None:
self.git_handler.set_cwd(cwd)
changes = self.git_handler.get_git_changes()
+6 -4
View File
@@ -376,6 +376,7 @@ class CLIRuntime(Runtime):
if ready_to_read:
line = process.stdout.readline()
if line:
logger.debug(f'LINE: {line}')
output_lines.append(line)
if self._shell_stream_callback:
self._shell_stream_callback(line)
@@ -386,6 +387,7 @@ class CLIRuntime(Runtime):
while line:
line = process.stdout.readline()
if line:
logger.debug(f'LINE: {line}')
output_lines.append(line)
if self._shell_stream_callback:
self._shell_stream_callback(line)
@@ -530,6 +532,10 @@ class CLIRuntime(Runtime):
file_path = self._sanitize_filename(action.path)
# Cannot read binary files
if os.path.exists(file_path) and is_binary(file_path):
return ErrorObservation('ERROR_BINARY_FILE')
# Use OHEditor for OH_ACI implementation source
if action.impl_source == FileReadSource.OH_ACI:
result_str, _ = self._execute_file_editor(
@@ -553,10 +559,6 @@ class CLIRuntime(Runtime):
if os.path.isdir(file_path):
return ErrorObservation(f'Cannot read directory: {action.path}')
# Cannot read binary files
if is_binary(file_path):
return ErrorObservation('ERROR_BINARY_FILE')
# Read the file
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
+9 -14
View File
@@ -575,30 +575,25 @@ class LocalRuntime(ActionExecutionClient):
# Fallback to localhost
return self.config.sandbox.local_runtime_url
def _create_url(self, prefix: str, port: int) -> str:
runtime_url = self.runtime_url
if 'localhost' in runtime_url:
url = f'{self.runtime_url}:{self._vscode_port}'
else:
# Similar to remote runtime...
parsed_url = urlparse(runtime_url)
url = f'{parsed_url.scheme}://{prefix}-{parsed_url.netloc}'
return url
@property
def vscode_url(self) -> str | None:
token = super().get_vscode_token()
if not token:
return None
vscode_url = self._create_url('vscode', self._vscode_port)
runtime_url = self.runtime_url
if 'localhost' in runtime_url:
vscode_url = f'{self.runtime_url}:{self._vscode_port}'
else:
# Similar to remote runtime...
parsed_url = urlparse(runtime_url)
vscode_url = f'{parsed_url.scheme}://vscode-{parsed_url.netloc}'
return f'{vscode_url}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
@property
def web_hosts(self) -> dict[str, int]:
hosts: dict[str, int] = {}
for index, port in enumerate(self._app_ports):
url = self._create_url(f'work-{index + 1}', port)
hosts[url] = port
for port in self._app_ports:
hosts[f'{self.runtime_url}:{port}'] = port
return hosts
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env python3
"""
Get git changes in the current working directory relative to the remote origin if possible.
NOTE: Since this is run as a script, there should be no imports from project files!
"""
import glob
import json
import os
import subprocess
from pathlib import Path
def run(cmd: str, cwd: str) -> str:
result = subprocess.run(
args=cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
)
byte_content = result.stderr or result.stdout or b''
if result.returncode != 0:
raise RuntimeError(
f'error_running_cmd:{result.returncode}:{byte_content.decode()}'
)
return byte_content.decode().strip()
def get_valid_ref(repo_dir: str) -> str | None:
refs = []
try:
current_branch = run('git --no-pager rev-parse --abbrev-ref HEAD', repo_dir)
refs.append(f'origin/{current_branch}')
except RuntimeError:
pass
try:
default_branch = (
run('git --no-pager remote show origin | grep "HEAD branch"', repo_dir)
.split()[-1]
.strip()
)
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = f'origin/{default_branch}'
refs.append(ref_non_default_branch)
refs.append(ref_default_branch)
except RuntimeError:
pass
# compares with empty tree
ref_new_repo = (
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
)
refs.append(ref_new_repo)
# Find a ref that exists...
for ref in refs:
try:
result = run(f'git --no-pager rev-parse --verify {ref}', repo_dir)
return result
except RuntimeError:
# invalid ref - try next
continue
return None
def get_changes_in_repo(repo_dir: str) -> list[dict[str, str]]:
# Gets the status relative to the origin default branch - not the same as `git status`
ref = get_valid_ref(repo_dir)
if not ref:
return []
# Get changed files
changed_files = run(
f'git --no-pager diff --name-status {ref}', repo_dir
).splitlines()
changes = []
for line in changed_files:
if not line.strip():
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
# Handle different output formats from git diff --name-status
# Depending on git config, format can be either:
# * "A file.txt"
# * "A file.txt"
# * "R100 old_file.txt new_file.txt" (rename with similarity percentage)
parts = line.split()
if len(parts) < 2:
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
status = parts[0].strip()
# Handle rename operations (status starts with 'R' followed by similarity percentage)
if status.startswith('R') and len(parts) == 3:
# Rename: convert to delete (old path) + add (new path)
old_path = parts[1].strip()
new_path = parts[2].strip()
changes.append(
{
'status': 'D',
'path': old_path,
}
)
changes.append(
{
'status': 'A',
'path': new_path,
}
)
continue
# Handle copy operations (status starts with 'C' followed by similarity percentage)
elif status.startswith('C') and len(parts) == 3:
# Copy: only add the new path (original remains)
new_path = parts[2].strip()
changes.append(
{
'status': 'A',
'path': new_path,
}
)
continue
# Handle regular operations (M, A, D, etc.)
elif len(parts) == 2:
path = parts[1].strip()
else:
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
if status == '??':
status = 'A'
elif status == '*':
status = 'M'
# Check for valid single-character status codes
if status in {'M', 'A', 'D', 'U'}:
changes.append(
{
'status': status,
'path': path,
}
)
else:
raise RuntimeError(f'unexpected_status_in_git_diff:{changed_files}')
# Get untracked files
untracked_files = run(
'git --no-pager ls-files --others --exclude-standard', repo_dir
).splitlines()
for path in untracked_files:
if path:
changes.append({'status': 'A', 'path': path})
return changes
def get_git_changes(cwd: str) -> list[dict[str, str]]:
git_dirs = {
os.path.dirname(f)[2:]
for f in glob.glob('./*/.git', root_dir=cwd, recursive=True)
}
# First try the workspace directory
changes = get_changes_in_repo(cwd)
# Filter out any changes which are in one of the git directories
changes = [
change
for change in changes
if next(
iter(git_dir for git_dir in git_dirs if change['path'].startswith(git_dir)),
None,
)
is None
]
# Add changes from git directories
for git_dir in git_dirs:
git_dir_changes = get_changes_in_repo(str(Path(cwd, git_dir)))
for change in git_dir_changes:
change['path'] = git_dir + '/' + change['path']
changes.append(change)
changes.sort(key=lambda change: change['path'])
return changes
if __name__ == '__main__':
try:
changes = get_git_changes(os.getcwd())
print(json.dumps(changes))
except Exception as e:
print(json.dumps({'error': str(e)}))
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""
Get git diff in a single git file for the closest git repo in the file system
NOTE: Since this is run as a script, there should be no imports from project files!
"""
import json
import os
import subprocess
import sys
from pathlib import Path
def get_closest_git_repo(path: Path) -> Path | None:
while True:
path = path.parent
git_path = Path(path, '.git')
if git_path.is_dir():
return path
if path.parent == path:
return None
def run(cmd: str, cwd: str) -> str:
result = subprocess.run(
args=cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
)
byte_content = result.stderr or result.stdout or b''
if result.returncode != 0:
raise RuntimeError(
f'error_running_cmd:{result.returncode}:{byte_content.decode()}'
)
return byte_content.decode().strip()
def get_valid_ref(repo_dir: str) -> str | None:
refs = []
try:
current_branch = run('git --no-pager rev-parse --abbrev-ref HEAD', repo_dir)
refs.append(f'origin/{current_branch}')
except RuntimeError:
pass
try:
default_branch = (
run('git --no-pager remote show origin | grep "HEAD branch"', repo_dir)
.split()[-1]
.strip()
)
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = f'origin/{default_branch}'
refs.append(ref_non_default_branch)
refs.append(ref_default_branch)
except RuntimeError:
pass
# compares with empty tree
ref_new_repo = (
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
)
refs.append(ref_new_repo)
# Find a ref that exists...
for ref in refs:
try:
result = run(f'git --no-pager rev-parse --verify {ref}', repo_dir)
return result
except RuntimeError:
# invalid ref - try next
continue
return None
def get_git_diff(relative_file_path: str) -> dict[str, str]:
path = Path(os.getcwd(), relative_file_path).resolve()
closest_git_repo = get_closest_git_repo(path)
if not closest_git_repo:
raise ValueError('no_repo')
current_rev = get_valid_ref(str(closest_git_repo))
try:
original = run(
f'git show "{current_rev}:{path.relative_to(closest_git_repo)}"',
str(closest_git_repo),
)
except RuntimeError:
original = ''
try:
with open(path, 'r') as f:
modified = '\n'.join(f.read().splitlines())
except FileNotFoundError:
modified = ''
return {
'modified': modified,
'original': original,
}
if __name__ == '__main__':
diff = get_git_diff(sys.argv[-1])
print(json.dumps(diff))
+219 -58
View File
@@ -1,15 +1,6 @@
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.utils import git_changes, git_diff
GIT_CHANGES_CMD = 'python3 /openhands/code/openhands/runtime/utils/git_changes.py'
GIT_DIFF_CMD = (
'python3 /openhands/code/openhands/runtime/utils/git_diff.py "{file_path}"'
)
from uuid import uuid4
@dataclass
@@ -34,13 +25,9 @@ class GitHandler:
def __init__(
self,
execute_shell_fn: Callable[[str, str | None], CommandResult],
create_file_fn: Callable[[str, str], int],
):
self.execute = execute_shell_fn
self.create_file_fn = create_file_fn
self.cwd: str | None = None
self.git_changes_cmd = GIT_CHANGES_CMD
self.git_diff_cmd = GIT_DIFF_CMD
def set_cwd(self, cwd: str) -> None:
"""
@@ -51,13 +38,148 @@ class GitHandler:
"""
self.cwd = cwd
def _create_python_script_file(self, file: str):
result = self.execute('mktemp -d', self.cwd)
script_file = Path(result.content.strip(), Path(file).name)
with open(file, 'r') as f:
self.create_file_fn(str(script_file), f.read())
result = self.execute(f'chmod +x "{script_file}"', self.cwd)
return script_file
def _is_git_repo(self) -> bool:
"""
Checks if the current directory is a Git repository.
Returns:
bool: True if inside a Git repository, otherwise False.
"""
cmd = 'git --no-pager rev-parse --is-inside-work-tree'
output = self.execute(cmd, self.cwd)
return output.content.strip() == 'true'
def _get_current_file_content(self, file_path: str) -> str:
"""
Retrieves the current content of a given file.
Args:
file_path (str): Path to the file.
Returns:
str: The file content.
"""
output = self.execute(f'cat {file_path}', self.cwd)
return output.content
def _verify_ref_exists(self, ref: str) -> bool:
"""
Verifies whether a specific Git reference exists.
Args:
ref (str): The Git reference to check.
Returns:
bool: True if the reference exists, otherwise False.
"""
cmd = f'git --no-pager rev-parse --verify {ref}'
output = self.execute(cmd, self.cwd)
return output.exit_code == 0
def _get_ref_content(self, file_path: str) -> str:
"""
Retrieves the content of a file from a valid Git reference.
Finds the git repository closest to the file in the tree and executes the command in that context.
Args:
file_path (str): The file path in the repository.
Returns:
str: The content of the file from the reference, or an empty string if unavailable.
"""
if not self.cwd:
return ''
unique_id = uuid4().hex
# Single bash command that finds the closest git repository to the file and gets the ref content
cmd = f"""bash -c '
# Convert to absolute path
file_path="$(realpath "{file_path}")"
# Find the closest git repository by walking up the directory tree
current_dir="$(dirname "$file_path")"
git_repo_dir=""
while [[ "$current_dir" != "/" ]]; do
if [[ -d "$current_dir/.git" ]] || git -C "$current_dir" rev-parse --git-dir >/dev/null 2>&1; then
git_repo_dir="$current_dir"
break
fi
current_dir="$(dirname "$current_dir")"
done
# If no git repository found, exit
if [[ -z "$git_repo_dir" ]]; then
exit 1
fi
# Get the file path relative to the git repository root
repo_root="$(cd "$git_repo_dir" && git rev-parse --show-toplevel)"
relative_file_path="${{file_path#${{repo_root}}/}}"
# Function to get current branch
get_current_branch() {{
git -C "$git_repo_dir" rev-parse --abbrev-ref HEAD 2>/dev/null
}}
# Function to get default branch
get_default_branch() {{
git -C "$git_repo_dir" remote show origin 2>/dev/null | grep "HEAD branch" | awk "{{print \\$NF}}" || echo "main"
}}
# Function to verify if a ref exists
verify_ref_exists() {{
git -C "$git_repo_dir" rev-parse --verify "$1" >/dev/null 2>&1
}}
# Get valid reference for comparison
current_branch="$(get_current_branch)"
default_branch="$(get_default_branch)"
# Check if origin remote exists
has_origin="$(git -C "$git_repo_dir" remote | grep -q "^origin$" && echo "true" || echo "false")"
if [[ "$has_origin" == "true" ]]; then
ref_current_branch="origin/$current_branch"
ref_non_default_branch="$(git -C "$git_repo_dir" merge-base HEAD "$(git -C "$git_repo_dir" rev-parse --abbrev-ref origin/$default_branch)" 2>/dev/null || echo "")"
ref_default_branch="origin/$default_branch"
else
# For repositories without origin, try HEAD~1 (previous commit) or empty tree
ref_current_branch="HEAD~1"
ref_non_default_branch=""
ref_default_branch=""
fi
ref_new_repo="$(git -C "$git_repo_dir" rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904 2>/dev/null || echo "")" # empty tree
# Try refs in order of preference
valid_ref=""
for ref in "$ref_current_branch" "$ref_non_default_branch" "$ref_default_branch" "$ref_new_repo"; do
if [[ -n "$ref" ]] && verify_ref_exists "$ref"; then
valid_ref="$ref"
break
fi
done
# If no valid ref found, exit
if [[ -z "$valid_ref" ]]; then
exit 1
fi
# Get the file content from the reference
git -C "$git_repo_dir" show "$valid_ref:$relative_file_path" 2>/dev/null || exit 1
# {unique_id}'"""
result = self.execute(cmd, self.cwd)
if result.exit_code != 0:
return ''
# TODO: The command echoes the bash script. Why?
content = result.content.split(f'{unique_id}')[-1]
return content
def get_git_changes(self) -> list[dict[str, str]] | None:
"""
@@ -73,31 +195,57 @@ class GitHandler:
if not self.cwd:
return None
result = self.execute(self.git_changes_cmd, self.cwd)
if result.exit_code == 0:
try:
changes = json.loads(result.content)
return changes
except Exception:
logger.exception(
'GitHandler:get_git_changes:error',
extra={'content': result.content},
)
return None
# Single bash command that:
# 1. Creates a list of directories to check (current dir + direct subdirectories)
# 2. For each directory, checks if it's a git repo and gets status
# 3. Outputs in format: REPO_PATH|STATUS|FILE_PATH
cmd = """bash -c '
{
# Check current directory first
echo "."
# List direct subdirectories (excluding hidden ones)
find . -maxdepth 1 -type d ! -name ".*" ! -name "." 2>/dev/null || true
} | while IFS= read -r dir; do
if [ -d "$dir/.git" ] || git -C "$dir" rev-parse --git-dir >/dev/null 2>&1; then
# Get absolute path of the directory
# Get git status for this repository
git -C "$dir" status --porcelain -uall 2>/dev/null | while IFS= read -r line; do
if [ -n "$line" ]; then
# Extract status (first 2 chars) and file path (from char 3 onwards)
status=$(echo "$line" | cut -c1-2)
file_path=$(echo "$line" | cut -c4-)
# Convert status codes to single character
case "$status" in
"M "*|" M") echo "$dir|M|$file_path" ;;
"A "*|" A") echo "$dir|A|$file_path" ;;
"D "*|" D") echo "$dir|D|$file_path" ;;
"R "*|" R") echo "$dir|R|$file_path" ;;
"C "*|" C") echo "$dir|C|$file_path" ;;
"U "*|" U") echo "$dir|U|$file_path" ;;
"??") echo "$dir|A|$file_path" ;;
*) echo "$dir|M|$file_path" ;;
esac
fi
done
fi
done
' """
if self.git_changes_cmd != GIT_CHANGES_CMD:
# We have already tried to add a script to the workspace - it did not work
result = self.execute(cmd.strip(), self.cwd)
if result.exit_code != 0 or not result.content.strip():
return None
# We try to add a script for getting git changes to the runtime - legacy runtimes may be missing the script
logger.info(
'GitHandler:get_git_changes: adding git_changes script to runtime...'
)
script_file = self._create_python_script_file(git_changes.__file__)
self.git_changes_cmd = f'python3 {script_file}'
# Parse the output
changes = []
for line in result.content.strip().split('\n'):
if '|' in line:
parts = line.split('|', 2)
if len(parts) == 3:
repo_path, status, file_path = parts
file_path = f'{repo_path}/{file_path}'[2:]
changes.append({'status': status, 'path': file_path})
# Try again with the new changes cmd
return self.get_git_changes()
return changes if changes else None
def get_git_diff(self, file_path: str) -> dict[str, str]:
"""
@@ -109,23 +257,36 @@ class GitHandler:
Returns:
dict[str, str]: A dictionary containing the original and modified content.
"""
# If cwd is not set, return None
if not self.cwd:
raise ValueError('no_dir_in_git_diff')
modified = self._get_current_file_content(file_path)
original = self._get_ref_content(file_path)
result = self.execute(self.git_diff_cmd.format(file_path=file_path), self.cwd)
if result.exit_code == 0:
diff = json.loads(result.content)
return diff
return {
'modified': modified,
'original': original,
}
if self.git_diff_cmd != GIT_DIFF_CMD:
# We have already tried to add a script to the workspace - it did not work
raise ValueError('error_in_git_diff')
# We try to add a script for getting git changes to the runtime - legacy runtimes may be missing the script
logger.info('GitHandler:get_git_diff: adding git_diff script to runtime...')
script_file = self._create_python_script_file(git_diff.__file__)
self.git_diff_cmd = f'python3 {script_file} "{{file_path}}"'
def parse_git_changes(changes_list: list[str]) -> list[dict[str, str]]:
"""
Parses the list of changed files and extracts their statuses and paths.
# Try again with the new changes cmd
return self.get_git_diff(file_path)
Args:
changes_list (list[str]): List of changed file entries.
Returns:
list[dict[str, str]]: Parsed list of file changes with statuses.
"""
result = []
for line in changes_list:
status = line[:2].strip()
path = line[2:].strip()
# Get the first non-space character as the primary status
primary_status = status.replace(' ', '')[0]
result.append(
{
'status': primary_status,
'path': path,
}
)
return result
+263 -87
View File
@@ -1,9 +1,15 @@
import os
import shutil
import subprocess
import tempfile
from datetime import datetime
from pathlib import Path
from types import MappingProxyType
from typing import cast
from fastapi import APIRouter, Depends, Query, status
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from pydantic import BaseModel, SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import (
@@ -13,15 +19,14 @@ from openhands.integrations.provider import (
from openhands.integrations.service_types import (
AuthenticationError,
Branch,
ProviderType,
Repository,
SuggestedTask,
UnknownException,
User,
)
from openhands.microagent.types import (
MicroagentContentResponse,
MicroagentResponse,
)
from openhands.microagent import load_microagents_from_dir
from openhands.microagent.types import InputMetadata
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import server_config
from openhands.server.user_auth import (
@@ -238,6 +243,144 @@ async def get_repository_branches(
)
class MicroagentResponse(BaseModel):
"""Response model for microagents endpoint."""
name: str
type: str
content: str
triggers: list[str] = []
inputs: list[InputMetadata] = []
tools: list[str] = []
created_at: datetime
git_provider: ProviderType
path: str # Path to the microagent in the Git provider (e.g., ".openhands/microagents/tell-me-a-joke")
def _get_file_creation_time(repo_dir: Path, file_path: Path) -> datetime:
"""Get the creation time of a file from Git history.
Args:
repo_dir: The root directory of the Git repository
file_path: The path to the file relative to the repository root
Returns:
datetime: The timestamp when the file was first added to the repository
"""
try:
# Get the relative path from the repository root
relative_path = file_path.relative_to(repo_dir)
# Use git log to get the first commit that added this file
# --follow: follow renames and moves
# --reverse: show commits in reverse chronological order (oldest first)
# --format=%ct: show commit timestamp in Unix format
# -1: limit to 1 result (the first commit)
cmd = [
'git',
'log',
'--follow',
'--reverse',
'--format=%ct',
'-1',
str(relative_path),
]
result = subprocess.run(
cmd, cwd=repo_dir, capture_output=True, text=True, timeout=10
)
if result.returncode == 0 and result.stdout.strip():
# Parse Unix timestamp and convert to datetime
timestamp = int(result.stdout.strip())
return datetime.fromtimestamp(timestamp)
else:
logger.warning(
f'Failed to get creation time for {relative_path}: {result.stderr}'
)
# Fallback to current time if git log fails
return datetime.now()
except Exception as e:
logger.warning(f'Error getting creation time for {file_path}: {str(e)}')
# Fallback to current time if there's any error
return datetime.now()
async def _verify_repository_access(
repository_name: str,
provider_tokens: PROVIDER_TOKEN_TYPE | None,
access_token: SecretStr | None,
user_id: str | None,
) -> Repository:
"""Verify repository access and return repository information.
Args:
repository_name: Repository name in the format 'owner/repo'
provider_tokens: Provider tokens for authentication
access_token: Access token for external authentication
user_id: User ID for authentication
Returns:
Repository object with provider information
Raises:
AuthenticationError: If authentication fails
"""
provider_handler = ProviderHandler(
provider_tokens=provider_tokens
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
external_auth_token=access_token,
external_auth_id=user_id,
)
repository = await provider_handler.verify_repo_provider(repository_name)
logger.info(
f'Detected git provider: {repository.git_provider} for repository: {repository_name}'
)
return repository
def _clone_repository(remote_url: str, repository_name: str) -> Path:
"""Clone repository to temporary directory.
Args:
remote_url: Authenticated git URL for cloning
repository_name: Repository name for error messages
Returns:
Path to the cloned repository directory
Raises:
RuntimeError: If cloning fails
"""
temp_dir = tempfile.mkdtemp()
repo_dir = Path(temp_dir) / 'repo'
clone_cmd = ['git', 'clone', '--depth', '1', remote_url, str(repo_dir)]
# Set environment variable to avoid interactive prompts
env = os.environ.copy()
env['GIT_TERMINAL_PROMPT'] = '0'
result = subprocess.run(
clone_cmd,
capture_output=True,
text=True,
env=env,
timeout=30, # 30 second timeout
)
if result.returncode != 0:
# Clean up on failure
shutil.rmtree(temp_dir, ignore_errors=True)
error_msg = f'Failed to clone repository: {result.stderr}'
logger.error(f'Failed to clone repository {repository_name}: {result.stderr}')
raise RuntimeError(error_msg)
return repo_dir
def _extract_repo_name(repository_name: str) -> str:
"""Extract the actual repository name from the full repository path.
@@ -250,6 +393,99 @@ def _extract_repo_name(repository_name: str) -> str:
return repository_name.split('/')[-1]
def _process_microagents(
repo_dir: Path,
repository_name: str,
git_provider: ProviderType,
) -> list[MicroagentResponse]:
"""Process microagents from the cloned repository.
Args:
repo_dir: Path to the cloned repository directory
repository_name: Repository name for logging
git_provider: Git provider type
Returns:
List of microagents found in the repository
"""
# Extract the actual repository name from the full path
actual_repo_name = _extract_repo_name(repository_name)
# Determine the microagents directory based on git provider and repository name
if git_provider != ProviderType.GITLAB and actual_repo_name == '.openhands':
# For non-GitLab providers with repository name ".openhands", scan "microagents" folder
microagents_dir = repo_dir / 'microagents'
elif git_provider == ProviderType.GITLAB and actual_repo_name == 'openhands-config':
# For GitLab with repository name "openhands-config", scan "microagents" folder
microagents_dir = repo_dir / 'microagents'
else:
# Default behavior: look for .openhands/microagents directory
microagents_dir = repo_dir / '.openhands' / 'microagents'
if not microagents_dir.exists():
logger.info(
f'No microagents directory found in {repository_name} at {microagents_dir}'
)
return []
# Load microagents from the directory
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
# Prepare response
microagents = []
# Add repo microagents
for name, r_agent in repo_agents.items():
# Get the actual creation time from Git
agent_file_path = Path(r_agent.source)
created_at = _get_file_creation_time(repo_dir, agent_file_path)
microagents.append(
MicroagentResponse(
name=name,
type='repo',
content=r_agent.content,
triggers=[],
inputs=r_agent.metadata.inputs,
tools=(
[server.name for server in r_agent.metadata.mcp_tools.stdio_servers]
if r_agent.metadata.mcp_tools
else []
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)
# Add knowledge microagents
for name, k_agent in knowledge_agents.items():
# Get the actual creation time from Git
agent_file_path = Path(k_agent.source)
created_at = _get_file_creation_time(repo_dir, agent_file_path)
microagents.append(
MicroagentResponse(
name=name,
type='knowledge',
content=k_agent.content,
triggers=k_agent.triggers,
inputs=k_agent.metadata.inputs,
tools=(
[server.name for server in k_agent.metadata.mcp_tools.stdio_servers]
if k_agent.metadata.mcp_tools
else []
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)
logger.info(f'Found {len(microagents)} microagents in {repository_name}')
return microagents
@app.get(
'/repository/{repository_name:path}/microagents',
response_model=list[MicroagentResponse],
@@ -267,9 +503,6 @@ async def get_repository_microagents(
- If git provider is GitLab and actual repository name is "openhands-config": scans "microagents" folder
- Otherwise: scans ".openhands/microagents" folder
Note: This API returns microagent metadata without content for performance.
Use the separate content API to fetch individual microagent content.
Args:
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
provider_tokens: Provider tokens for authentication
@@ -277,21 +510,33 @@ async def get_repository_microagents(
user_id: User ID for authentication
Returns:
List of microagents found in the repository's microagents directory (without content)
List of microagents found in the repository's microagents directory
"""
repo_dir = None
try:
# Create provider handler for API authentication
# Verify repository access and get provider information
repository = await _verify_repository_access(
repository_name, provider_tokens, access_token, user_id
)
# Construct authenticated git URL using provider handler
provider_handler = ProviderHandler(
provider_tokens=provider_tokens
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
external_auth_token=access_token,
external_auth_id=user_id,
)
remote_url = await provider_handler.get_authenticated_git_url(repository_name)
# Fetch microagents using the provider handler
microagents = await provider_handler.get_microagents(repository_name)
# Clone repository
repo_dir = _clone_repository(remote_url, repository_name)
# Process microagents
microagents = _process_microagents(
repo_dir, repository_name, repository.git_provider
)
logger.info(f'Found {len(microagents)} microagents in {repository_name}')
return microagents
except AuthenticationError as e:
@@ -318,76 +563,7 @@ async def get_repository_microagents(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@app.get(
'/repository/{repository_name:path}/microagents/content',
response_model=MicroagentContentResponse,
)
async def get_repository_microagent_content(
repository_name: str,
file_path: str = Query(
..., description='Path to the microagent file within the repository'
),
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> MicroagentContentResponse | JSONResponse:
"""Fetch the content of a specific microagent file from a repository.
Args:
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
file_path: Query parameter - Path to the microagent file within the repository
provider_tokens: Provider tokens for authentication
access_token: Access token for external authentication
user_id: User ID for authentication
Returns:
Microagent file content and metadata
Example:
GET /api/user/repository/owner/repo/microagents/content?file_path=.openhands/microagents/my-agent.md
"""
try:
# Create provider handler for API authentication
provider_handler = ProviderHandler(
provider_tokens=provider_tokens
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
external_auth_token=access_token,
external_auth_id=user_id,
)
# Fetch file content using the provider handler
response = await provider_handler.get_microagent_content(
repository_name, file_path
)
logger.info(
f'Retrieved content for microagent {file_path} from {repository_name}'
)
return response
except AuthenticationError as e:
logger.info(
f'Returning 401 Unauthorized - Authentication error for user_id: {user_id}, error: {str(e)}'
)
return JSONResponse(
content=str(e),
status_code=status.HTTP_401_UNAUTHORIZED,
)
except RuntimeError as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except Exception as e:
logger.error(
f'Error fetching microagent content from {repository_name}/{file_path}: {str(e)}',
exc_info=True,
)
return JSONResponse(
content=f'Error fetching microagent content: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
finally:
# Clean up temporary directory
if repo_dir and repo_dir.parent.exists():
shutil.rmtree(repo_dir.parent, ignore_errors=True)
-5
View File
@@ -28,7 +28,6 @@ from openhands.events.observation.agent import RecallObservation
from openhands.events.observation.error import ErrorObservation
from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.events.stream import EventStreamSubscriber
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.llm.llm import LLM
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.session.agent_session import AgentSession
@@ -158,10 +157,6 @@ class Session:
llm = self._create_llm(agent_cls)
agent_config = self.config.get_agent_config(agent_cls)
agent_config = ExperimentManagerImpl.run_agent_config_variant_test(
self.user_id, self.sid, agent_config
)
if settings.enable_default_condenser:
# Default condenser chains three condensers together:
# 1. a conversation window condenser that handles explicit
@@ -52,11 +52,6 @@ class DefaultUserAuth(UserAuth):
return settings
settings_store = await self.get_user_settings_store()
settings = await settings_store.load()
# Merge config.toml settings with stored settings
if settings:
settings = settings.merge_with_config_settings()
self._settings = settings
return settings
-30
View File
@@ -137,33 +137,3 @@ class Settings(BaseModel):
max_budget_per_task=app_config.max_budget_per_task,
)
return settings
def merge_with_config_settings(self) -> 'Settings':
"""Merge config.toml settings with stored settings.
Config.toml takes priority for MCP settings, but they are merged rather than replaced.
This method can be used by both server mode and CLI mode.
"""
# Get config.toml settings
config_settings = Settings.from_config()
if not config_settings or not config_settings.mcp_config:
return self
# If stored settings don't have MCP config, use config.toml MCP config
if not self.mcp_config:
self.mcp_config = config_settings.mcp_config
return self
# Both have MCP config - merge them with config.toml taking priority
merged_mcp = MCPConfig(
sse_servers=list(config_settings.mcp_config.sse_servers)
+ list(self.mcp_config.sse_servers),
stdio_servers=list(config_settings.mcp_config.stdio_servers)
+ list(self.mcp_config.stdio_servers),
shttp_servers=list(config_settings.mcp_config.shttp_servers)
+ list(self.mcp_config.shttp_servers),
)
# Create new settings with merged MCP config
self.mcp_config = merged_mcp
return self
-1
View File
@@ -64,7 +64,6 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
'openhands/devstral-small-2507',
'openhands/devstral-medium-2507',
'openhands/kimi-k2-0711-preview',
'openhands/qwen3-coder-480b',
]
model_list = openhands_models + model_list
Generated
+87 -1
View File
@@ -5487,6 +5487,25 @@ files = [
[package.dependencies]
psutil = "*"
[[package]]
name = "minio"
version = "7.2.16"
description = "MinIO Python SDK for Amazon S3 Compatible Cloud Storage"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "minio-7.2.16-py3-none-any.whl", hash = "sha256:9288ab988ca57c181eb59a4c96187b293131418e28c164392186c2b89026b223"},
{file = "minio-7.2.16.tar.gz", hash = "sha256:81e365c8494d591d8204a63ee7596bfdf8a7d06ad1b1507d6b9c1664a95f299a"},
]
[package.dependencies]
argon2-cffi = "*"
certifi = "*"
pycryptodome = "*"
typing-extensions = "*"
urllib3 = "*"
[[package]]
name = "mistune"
version = "3.1.3"
@@ -7509,6 +7528,57 @@ files = [
]
markers = {test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
[[package]]
name = "pycryptodome"
version = "3.23.0"
description = "Cryptographic library for Python"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
files = [
{file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"},
{file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"},
{file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"},
{file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"},
{file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"},
{file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"},
{file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"},
{file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"},
{file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"},
{file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"},
{file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"},
{file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"},
{file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"},
{file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"},
{file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"},
{file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"},
{file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"},
{file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"},
{file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"},
{file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"},
{file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"},
{file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"},
{file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"},
{file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"},
{file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"},
{file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"},
{file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"},
{file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"},
{file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"},
{file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"},
{file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"},
]
[[package]]
name = "pydantic"
version = "2.11.5"
@@ -9674,6 +9744,22 @@ docs = ["myst-parser[linkify]", "sphinx", "sphinx-rtd-theme"]
release = ["twine"]
test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"]
[[package]]
name = "stripe"
version = "12.3.0"
description = "Python bindings for the Stripe API"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "stripe-12.3.0-py2.py3-none-any.whl", hash = "sha256:f53daf37253cef0323613aa298b66d2d2081d37d0f2e4d9f8639824bf67185b9"},
{file = "stripe-12.3.0.tar.gz", hash = "sha256:ad8afdab8acdbd75fc098b0fefdfee698f68334a3f6e787633e8d290da89932b"},
]
[package.dependencies]
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
typing_extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
[[package]]
name = "swebench"
version = "4.0.4"
@@ -11754,4 +11840,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "4640c66849d6436eed73826154e2d8cf88b456a4d1b71efb9438531245845826"
content-hash = "b89a5ec4de63ea1f2adb0b6ba171778ff25313fd29cd81a7cec5c957b23a9dc9"
+2 -1
View File
@@ -73,7 +73,6 @@ pythonnet = "*"
fastmcp = "^2.5.2"
python-frontmatter = "^1.1.0"
shellingham = "^1.5.4"
deprecated = "*"
# TODO: Should these go into the runtime group?
ipywidgets = "^8.1.5"
qtconsole = "^5.6.1"
@@ -85,7 +84,9 @@ bashlex = "^0.18"
# TODO: These are integrations that should probably be optional
redis = ">=5.2,<7.0"
minio = "^7.2.8"
stripe = ">=11.5,<13.0"
google-cloud-aiplatform = "*"
anthropic = { extras = [ "vertex" ], version = "*" }
boto3 = "*"
+1 -24
View File
@@ -75,36 +75,13 @@ def test_agent_finish_action_serialization_deserialization():
'args': {
'outputs': {},
'thought': '',
'task_completed': None,
'final_thought': '',
},
}
serialization_deserialization(original_action_dict, AgentFinishAction)
def test_agent_finish_action_legacy_task_completed_serialization():
"""Test that old conversations with task_completed can still be loaded."""
original_action_dict = {
'action': 'finish',
'args': {
'outputs': {},
'thought': '',
'final_thought': 'Task completed',
'task_completed': 'true', # This should be ignored during deserialization
},
}
# This should work without errors - task_completed should be stripped out
event = event_from_dict(original_action_dict)
assert isinstance(event, Action)
assert isinstance(event, AgentFinishAction)
assert event.final_thought == 'Task completed'
# task_completed attribute should not exist anymore
assert not hasattr(event, 'task_completed')
# When serialized back, task_completed should not be present
event_dict = event_to_dict(event)
assert 'task_completed' not in event_dict['args']
def test_agent_reject_action_serialization_deserialization():
original_action_dict = {
'action': 'reject',
+1 -8
View File
@@ -825,14 +825,7 @@ async def test_config_loading_order(
mock_config = MagicMock()
mock_config.workspace_base = '/test/dir'
mock_config.cli_multiline_input = False
# Create a mock LLM config that has no model or API key set
# This simulates the case where config.toml doesn't have LLM settings
mock_llm_config = MagicMock()
mock_llm_config.model = None
mock_llm_config.api_key = None
mock_config.get_llm_config = MagicMock(return_value=mock_llm_config)
mock_config.get_llm_config = MagicMock(return_value=MagicMock())
mock_config.set_llm_config = MagicMock()
mock_config.get_agent_config = MagicMock(return_value=MagicMock())
mock_config.set_agent_config = MagicMock()
+2 -2
View File
@@ -549,8 +549,8 @@ class TestHandleSettingsCommand:
config = MagicMock(spec=OpenHandsConfig)
settings_store = MagicMock(spec=FileSettingsStore)
# Mock user selecting "Go back" (now option 4, index 3)
mock_cli_confirm.return_value = 3
# Mock user selecting "Go back"
mock_cli_confirm.return_value = 2
# Call the function under test
await handle_settings_command(config, settings_store)
@@ -1,204 +0,0 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from litellm.exceptions import AuthenticationError
from openhands.cli import main as cli
from openhands.events import EventSource
from openhands.events.action import MessageAction
@pytest_asyncio.fixture
def mock_agent():
agent = AsyncMock()
agent.reset = MagicMock()
return agent
@pytest_asyncio.fixture
def mock_runtime():
runtime = AsyncMock()
runtime.close = MagicMock()
runtime.event_stream = MagicMock()
return runtime
@pytest_asyncio.fixture
def mock_controller():
controller = AsyncMock()
controller.close = AsyncMock()
# Setup for get_state() and the returned state's save_to_session()
mock_state = MagicMock()
mock_state.save_to_session = MagicMock()
controller.get_state = MagicMock(return_value=mock_state)
return controller
@pytest_asyncio.fixture
def mock_config():
config = MagicMock()
config.runtime = 'local'
config.cli_multiline_input = False
config.workspace_base = '/test/dir'
# Set up LLM config to use OpenHands provider
llm_config = MagicMock()
llm_config.model = 'openhands/o3' # Use OpenHands provider with o3 model
llm_config.api_key = MagicMock()
llm_config.api_key.get_secret_value.return_value = 'invalid-api-key'
config.llm = llm_config
# Mock search_api_key with get_secret_value method
search_api_key_mock = MagicMock()
search_api_key_mock.get_secret_value.return_value = (
'' # Empty string, not starting with 'tvly-'
)
config.search_api_key = search_api_key_mock
# Mock sandbox with volumes attribute to prevent finalize_config issues
config.sandbox = MagicMock()
config.sandbox.volumes = (
None # This prevents finalize_config from overriding workspace_base
)
return config
@pytest_asyncio.fixture
def mock_settings_store():
settings_store = AsyncMock()
return settings_store
@pytest.mark.asyncio
@patch('openhands.cli.main.display_runtime_initialization_message')
@patch('openhands.cli.main.display_initialization_animation')
@patch('openhands.cli.main.create_agent')
@patch('openhands.cli.main.add_mcp_tools_to_agent')
@patch('openhands.cli.main.create_runtime')
@patch('openhands.cli.main.create_controller')
@patch('openhands.cli.main.create_memory')
@patch('openhands.cli.main.run_agent_until_done')
@patch('openhands.cli.main.cleanup_session')
@patch('openhands.cli.main.initialize_repository_for_runtime')
@patch('openhands.llm.llm.litellm_completion')
async def test_openhands_provider_authentication_error(
mock_litellm_completion,
mock_initialize_repo,
mock_cleanup_session,
mock_run_agent_until_done,
mock_create_memory,
mock_create_controller,
mock_create_runtime,
mock_add_mcp_tools,
mock_create_agent,
mock_display_animation,
mock_display_runtime_init,
mock_config,
mock_settings_store,
):
"""Test that authentication errors with the OpenHands provider are handled correctly.
This test reproduces the error seen in the CLI when using the OpenHands provider:
```
litellm.exceptions.AuthenticationError: litellm.AuthenticationError: AuthenticationError: Litellm_proxyException -
Authentication Error, Invalid proxy server token passed. Received API Key = sk-...7hlQ,
Key Hash (Token) =e316fa114498880be11f2e236d6f482feee5e324a4a148b98af247eded5290c4.
Unable to find token in cache or `LiteLLM_VerificationTokenTable`
18:38:53 - openhands:ERROR: loop.py:25 - STATUS$ERROR_LLM_AUTHENTICATION
```
The test mocks the litellm_completion function to raise an AuthenticationError
with the OpenHands provider and verifies that the CLI handles the error gracefully.
"""
loop = asyncio.get_running_loop()
# Mock initialize_repository_for_runtime to return a valid path
mock_initialize_repo.return_value = '/test/dir'
# Mock objects returned by the setup functions
mock_agent = AsyncMock()
mock_create_agent.return_value = mock_agent
mock_runtime = AsyncMock()
mock_runtime.event_stream = MagicMock()
mock_create_runtime.return_value = mock_runtime
mock_controller = AsyncMock()
mock_controller_task = MagicMock()
mock_create_controller.return_value = (mock_controller, mock_controller_task)
# Create a regular MagicMock for memory to avoid coroutine issues
mock_memory = MagicMock()
mock_create_memory.return_value = mock_memory
# Mock the litellm_completion function to raise an AuthenticationError
# This simulates the exact error seen in the user's issue
auth_error_message = (
'litellm.AuthenticationError: AuthenticationError: Litellm_proxyException - '
'Authentication Error, Invalid proxy server token passed. Received API Key = sk-...7hlQ, '
'Key Hash (Token) =e316fa114498880be11f2e236d6f482feee5e324a4a148b98af247eded5290c4. '
'Unable to find token in cache or `LiteLLM_VerificationTokenTable`'
)
mock_litellm_completion.side_effect = AuthenticationError(
message=auth_error_message, llm_provider='litellm_proxy', model='o3'
)
with patch(
'openhands.cli.main.read_prompt_input', new_callable=AsyncMock
) as mock_read_prompt:
# Set up read_prompt_input to return a string that will trigger the command handler
mock_read_prompt.return_value = '/exit'
# Mock handle_commands to return values that will exit the loop
with patch(
'openhands.cli.main.handle_commands', new_callable=AsyncMock
) as mock_handle_commands:
mock_handle_commands.return_value = (
True,
False,
False,
) # close_repl, reload_microagents, new_session_requested
# Mock logger.error to capture the error message
with patch('openhands.core.logger.openhands_logger.error'):
# Run the function with an initial action that will trigger the OpenHands provider
initial_action_content = 'Hello, I need help with a task'
# Run the function
result = await cli.run_session(
loop,
mock_config,
mock_settings_store,
'/test/dir',
initial_action_content,
)
# Check that an event was added to the event stream
mock_runtime.event_stream.add_event.assert_called_once()
call_args = mock_runtime.event_stream.add_event.call_args[0]
assert isinstance(call_args[0], MessageAction)
# The CLI might modify the initial message, so we don't check the exact content
assert call_args[1] == EventSource.USER
# Check that run_agent_until_done was called
mock_run_agent_until_done.assert_called_once()
# Since we're mocking the litellm_completion function to raise an AuthenticationError,
# we can verify that the error was handled by checking that the run_agent_until_done
# function was called and the session was cleaned up properly
# We can't directly check the error message in the test since the logger.error
# method isn't being called in our mocked environment. In a real environment,
# the error would be logged and the user would see the improved error message.
# Check that cleanup_session was called
mock_cleanup_session.assert_called_once()
# Check that the function returns the expected value
assert result is False
-93
View File
@@ -9,7 +9,6 @@ from openhands.cli.settings import (
display_settings,
modify_llm_settings_advanced,
modify_llm_settings_basic,
modify_search_api_settings,
)
from openhands.cli.tui import UserCancelledError
from openhands.core.config import OpenHandsConfig
@@ -47,7 +46,6 @@ class TestDisplaySettings:
config.security = security_mock
config.enable_default_condenser = True
config.search_api_key = SecretStr('tvly-test-key')
return config
@pytest.fixture
@@ -67,7 +65,6 @@ class TestDisplaySettings:
config.security = security_mock
config.enable_default_condenser = True
config.search_api_key = SecretStr('tvly-test-key')
return config
@patch('openhands.cli.settings.print_container')
@@ -93,8 +90,6 @@ class TestDisplaySettings:
assert 'Enabled' in settings_text
assert 'Memory Condensation:' in settings_text
assert 'Enabled' in settings_text
assert 'Search API Key:' in settings_text
assert '********' in settings_text # Search API key should be masked
assert 'Configuration File' in settings_text
assert str(Path(app_config.file_store_path)) in settings_text
@@ -630,91 +625,3 @@ class TestModifyLLMSettingsAdvanced:
# Verify settings were not changed
app_config.set_llm_config.assert_not_called()
settings_store.store.assert_not_called()
class TestModifySearchApiSettings:
@pytest.fixture
def app_config(self):
config = MagicMock(spec=OpenHandsConfig)
config.search_api_key = SecretStr('tvly-existing-key')
return config
@pytest.fixture
def settings_store(self):
store = MagicMock(spec=FileSettingsStore)
store.load = AsyncMock(return_value=Settings())
store.store = AsyncMock()
return store
@pytest.mark.asyncio
@patch('openhands.cli.settings.PromptSession')
@patch('openhands.cli.settings.cli_confirm')
@patch('openhands.cli.settings.print_formatted_text')
async def test_modify_search_api_settings_set_new_key(
self, mock_print, mock_confirm, mock_session, app_config, settings_store
):
# Setup mocks
session_instance = MagicMock()
session_instance.prompt_async = AsyncMock(return_value='tvly-new-key')
mock_session.return_value = session_instance
# Mock user confirmations: Set/Update API Key, then Save
mock_confirm.side_effect = [0, 0]
# Call the function
await modify_search_api_settings(app_config, settings_store)
# Verify config was updated
assert app_config.search_api_key.get_secret_value() == 'tvly-new-key'
# Verify settings were saved
settings_store.store.assert_called_once()
args, kwargs = settings_store.store.call_args
settings = args[0]
assert settings.search_api_key.get_secret_value() == 'tvly-new-key'
@pytest.mark.asyncio
@patch('openhands.cli.settings.PromptSession')
@patch('openhands.cli.settings.cli_confirm')
@patch('openhands.cli.settings.print_formatted_text')
async def test_modify_search_api_settings_remove_key(
self, mock_print, mock_confirm, mock_session, app_config, settings_store
):
# Setup mocks
session_instance = MagicMock()
mock_session.return_value = session_instance
# Mock user confirmations: Remove API Key, then Save
mock_confirm.side_effect = [1, 0]
# Call the function
await modify_search_api_settings(app_config, settings_store)
# Verify config was updated to None
assert app_config.search_api_key is None
# Verify settings were saved
settings_store.store.assert_called_once()
args, kwargs = settings_store.store.call_args
settings = args[0]
assert settings.search_api_key is None
@pytest.mark.asyncio
@patch('openhands.cli.settings.PromptSession')
@patch('openhands.cli.settings.cli_confirm')
@patch('openhands.cli.settings.print_formatted_text')
async def test_modify_search_api_settings_keep_current(
self, mock_print, mock_confirm, mock_session, app_config, settings_store
):
# Setup mocks
session_instance = MagicMock()
mock_session.return_value = session_instance
# Mock user confirmation: Keep current setting
mock_confirm.return_value = 2
# Call the function
await modify_search_api_settings(app_config, settings_store)
# Verify settings were not changed
settings_store.store.assert_not_called()
-310
View File
@@ -1,310 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from openhands.core.config import (
OpenHandsConfig,
get_llm_config_arg,
setup_config_from_args,
)
@pytest.fixture
def default_config():
"""Fixture to provide a default OpenHandsConfig instance."""
yield OpenHandsConfig()
@pytest.fixture
def temp_config_files(tmp_path):
"""Create temporary config files for testing precedence."""
# Create a directory structure mimicking ~/.openhands/
user_config_dir = tmp_path / 'home' / '.openhands'
user_config_dir.mkdir(parents=True, exist_ok=True)
# Create ~/.openhands/config.toml
user_config_toml = user_config_dir / 'config.toml'
user_config_toml.write_text("""
[llm]
model = "user-home-model"
api_key = "user-home-api-key"
[llm.user-llm]
model = "user-specific-model"
api_key = "user-specific-api-key"
""")
# Create ~/.openhands/settings.json
user_settings_json = user_config_dir / 'settings.json'
user_settings_json.write_text("""
{
"LLM_MODEL": "settings-json-model",
"LLM_API_KEY": "settings-json-api-key"
}
""")
# Create current directory config.toml
current_dir_toml = tmp_path / 'current' / 'config.toml'
current_dir_toml.parent.mkdir(parents=True, exist_ok=True)
current_dir_toml.write_text("""
[llm]
model = "current-dir-model"
api_key = "current-dir-api-key"
[llm.current-dir-llm]
model = "current-dir-specific-model"
api_key = "current-dir-specific-api-key"
""")
return {
'user_config_toml': str(user_config_toml),
'user_settings_json': str(user_settings_json),
'current_dir_toml': str(current_dir_toml),
'home_dir': str(user_config_dir.parent),
'current_dir': str(current_dir_toml.parent),
}
@patch('openhands.core.config.utils.os.path.expanduser')
def test_llm_config_precedence_cli_highest(mock_expanduser, temp_config_files):
"""Test that CLI parameters have the highest precedence."""
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock args with CLI parameters
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = 'current-dir-llm' # Specify LLM via CLI
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config with CLI parameters
with patch('os.path.exists', return_value=True):
config = setup_config_from_args(mock_args)
# Verify CLI parameter takes precedence
assert config.get_llm_config().model == 'current-dir-specific-model'
assert (
config.get_llm_config().api_key.get_secret_value()
== 'current-dir-specific-api-key'
)
@patch('openhands.core.config.utils.os.path.expanduser')
def test_current_dir_toml_precedence_over_user_config(
mock_expanduser, temp_config_files
):
"""Test that config.toml in current directory has precedence over ~/.openhands/config.toml."""
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock args without CLI parameters
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = None # No CLI parameter
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config without CLI parameters
with patch('os.path.exists', return_value=True):
config = setup_config_from_args(mock_args)
# Verify current directory config.toml takes precedence over user config
assert config.get_llm_config().model == 'current-dir-model'
assert config.get_llm_config().api_key.get_secret_value() == 'current-dir-api-key'
@patch('openhands.core.config.utils.os.path.expanduser')
def test_get_llm_config_arg_precedence(mock_expanduser, temp_config_files):
"""Test that get_llm_config_arg prioritizes the specified config file."""
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# First try to load from current directory config
with patch('os.path.exists', return_value=True):
llm_config = get_llm_config_arg(
'current-dir-llm', temp_config_files['current_dir_toml']
)
# Verify it loaded from current directory config
assert llm_config.model == 'current-dir-specific-model'
assert llm_config.api_key.get_secret_value() == 'current-dir-specific-api-key'
# Now try to load a config that doesn't exist
# We need to patch setup_config_from_args to handle the fallback to user config
with patch(
'os.path.exists',
return_value=False,
):
llm_config = get_llm_config_arg(
'user-llm', temp_config_files['current_dir_toml']
)
# Verify it returns None when config not found (no automatic fallback)
assert llm_config is None
@patch('openhands.core.config.utils.os.path.expanduser')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.FileSettingsStore.load')
def test_cli_main_settings_precedence(
mock_load, mock_get_instance, mock_expanduser, temp_config_files
):
"""Test that the CLI main.py correctly applies settings precedence."""
from openhands.cli.main import setup_config_from_args
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock settings
mock_settings = MagicMock()
mock_settings.llm_model = 'settings-store-model'
mock_settings.llm_api_key = 'settings-store-api-key'
mock_settings.llm_base_url = None
mock_settings.agent = 'CodeActAgent'
mock_settings.confirmation_mode = False
mock_settings.enable_default_condenser = True
# Setup mocks
mock_load.return_value = mock_settings
mock_get_instance.return_value = MagicMock()
# Create mock args with config file pointing to current directory config
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = None # No CLI parameter
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config using the actual CLI code path
with patch('os.path.exists', return_value=True):
config = setup_config_from_args(mock_args)
# Verify that config.toml values take precedence over settings.json
assert config.get_llm_config().model == 'current-dir-model'
assert config.get_llm_config().api_key.get_secret_value() == 'current-dir-api-key'
@patch('openhands.core.config.utils.os.path.expanduser')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.FileSettingsStore.load')
def test_cli_with_l_parameter_precedence(
mock_load, mock_get_instance, mock_expanduser, temp_config_files
):
"""Test that CLI -l parameter has highest precedence in CLI mode."""
from openhands.cli.main import setup_config_from_args
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock settings
mock_settings = MagicMock()
mock_settings.llm_model = 'settings-store-model'
mock_settings.llm_api_key = 'settings-store-api-key'
mock_settings.llm_base_url = None
mock_settings.agent = 'CodeActAgent'
mock_settings.confirmation_mode = False
mock_settings.enable_default_condenser = True
# Setup mocks
mock_load.return_value = mock_settings
mock_get_instance.return_value = MagicMock()
# Create mock args with -l parameter
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = 'current-dir-llm' # Specify LLM via CLI
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config using the actual CLI code path
with patch('os.path.exists', return_value=True):
config = setup_config_from_args(mock_args)
# Verify that -l parameter takes precedence over everything
assert config.get_llm_config().model == 'current-dir-specific-model'
assert (
config.get_llm_config().api_key.get_secret_value()
== 'current-dir-specific-api-key'
)
@patch('openhands.core.config.utils.os.path.expanduser')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.FileSettingsStore.load')
def test_cli_settings_json_not_override_config_toml(
mock_load, mock_get_instance, mock_expanduser, temp_config_files
):
"""Test that settings.json doesn't override config.toml in CLI mode."""
import importlib
import sys
from unittest.mock import patch
# First, ensure we can import the CLI main module
if 'openhands.cli.main' in sys.modules:
importlib.reload(sys.modules['openhands.cli.main'])
# Now import the specific function we want to test
from openhands.cli.main import setup_config_from_args
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock settings with different values than config.toml
mock_settings = MagicMock()
mock_settings.llm_model = 'settings-json-model'
mock_settings.llm_api_key = 'settings-json-api-key'
mock_settings.llm_base_url = None
mock_settings.agent = 'CodeActAgent'
mock_settings.confirmation_mode = False
mock_settings.enable_default_condenser = True
# Setup mocks
mock_load.return_value = mock_settings
mock_get_instance.return_value = MagicMock()
# Create mock args with config file pointing to current directory config
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = None # No CLI parameter
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config using the actual CLI code path
with patch('os.path.exists', return_value=True):
setup_config_from_args(mock_args)
# Create a test LLM config to simulate the fix in CLI main.py
test_config = OpenHandsConfig()
test_llm_config = test_config.get_llm_config()
test_llm_config.model = 'config-toml-model'
test_llm_config.api_key = 'config-toml-api-key'
# Simulate the CLI main.py logic that we fixed
if not mock_args.llm_config and (test_llm_config.model or test_llm_config.api_key):
# Should NOT apply settings from settings.json
pass
else:
# This branch should not be taken in our test
test_llm_config.model = mock_settings.llm_model
test_llm_config.api_key = mock_settings.llm_api_key
# Verify that settings.json did not override config.toml
assert test_llm_config.model == 'config-toml-model'
assert test_llm_config.api_key == 'config-toml-api-key'
File diff suppressed because it is too large Load Diff
+168 -235
View File
@@ -1,19 +1,12 @@
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pytest
from openhands.runtime.utils import git_changes, git_diff, git_handler
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
@pytest.mark.skipif(sys.platform == 'win32', reason='Windows is not supported')
class TestGitHandler(unittest.TestCase):
def setUp(self):
# Create temporary directories for our test repositories
@@ -27,17 +20,11 @@ class TestGitHandler(unittest.TestCase):
# Track executed commands for verification
self.executed_commands = []
self.created_files = []
# Initialize the GitHandler with our mock functions
self.git_handler = GitHandler(
execute_shell_fn=self._execute_command, create_file_fn=self._create_file
)
# Initialize the GitHandler with our real execute function
self.git_handler = GitHandler(self._execute_command)
self.git_handler.set_cwd(self.local_dir)
self.git_handler.git_changes_cmd = f'python3 {git_changes.__file__}'
self.git_handler.git_diff_cmd = f'python3 {git_diff.__file__} "{{file_path}}"'
# Set up the git repositories
self._setup_git_repos()
@@ -47,256 +34,202 @@ class TestGitHandler(unittest.TestCase):
def _execute_command(self, cmd, cwd=None):
"""Execute a shell command and return the result."""
result = subprocess.run(
args=cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd,
)
stderr = result.stderr or b''
stdout = result.stdout or b''
return CommandResult((stderr + stdout).decode(), result.returncode)
def run_command(self, cmd, cwd=None):
result = self._execute_command(cmd, cwd)
if result.exit_code != 0:
raise RuntimeError(
f'command_error:{cmd};{result.exit_code};{result.content}'
)
def _create_file(self, path, content):
"""Mock function for creating files."""
self.created_files.append((path, content))
self.executed_commands.append((cmd, cwd))
try:
with open(path, 'w') as f:
f.write(content)
return 0
except Exception:
return -1
def write_file(
self,
dir: str,
name: str,
additional_content: tuple[str, ...] = ('Line 1', 'Line 2', 'Line 3'),
):
with open(os.path.join(dir, name), 'w') as f:
f.write(name)
for line in additional_content:
f.write('\n')
f.write(line)
assert os.path.exists(os.path.join(dir, name))
result = subprocess.run(
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
)
return CommandResult(result.stdout, result.returncode)
except Exception as e:
return CommandResult(str(e), 1)
def _setup_git_repos(self):
"""Set up real git repositories for testing."""
# Set up origin repository
self.run_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
"git config user.email 'test@example.com'", self.origin_dir
'git --no-pager init --initial-branch=main', self.origin_dir
)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.origin_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Set up the initial state...
self.write_file(self.origin_dir, 'unchanged.txt')
self.write_file(self.origin_dir, 'committed_modified.txt')
self.write_file(self.origin_dir, 'staged_modified.txt')
self.write_file(self.origin_dir, 'unstaged_modified.txt')
self.write_file(self.origin_dir, 'committed_delete.txt')
self.write_file(self.origin_dir, 'staged_delete.txt')
self.write_file(self.origin_dir, 'unstaged_delete.txt')
self.run_command("git add . && git commit -m 'Initial Commit'", self.origin_dir)
# Create a file and commit it
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
f.write('Original content')
self._execute_command('git --no-pager add file1.txt', self.origin_dir)
self._execute_command(
"git --no-pager commit -m 'Initial commit'", self.origin_dir
)
# Clone the origin repository to local
self.run_command(f'git clone "{self.origin_dir}" "{self.local_dir}"')
self._execute_command(
"git config user.email 'test@example.com'", self.local_dir
f'git --no-pager clone {self.origin_dir} {self.local_dir}'
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
self.run_command('git checkout -b feature-branch', self.local_dir)
# Setup committed changes...
self.write_file(self.local_dir, 'committed_modified.txt', ('Line 4',))
self.write_file(self.local_dir, 'committed_add.txt')
os.remove(os.path.join(self.local_dir, 'committed_delete.txt'))
self.run_command(
"git add . && git commit -m 'First batch of changes'", self.local_dir
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.local_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.local_dir
)
# Setup staged changes...
self.write_file(self.local_dir, 'staged_modified.txt', ('Line 4',))
self.write_file(self.local_dir, 'staged_add.txt')
os.remove(os.path.join(self.local_dir, 'staged_delete.txt'))
self.run_command('git add .', self.local_dir)
# Create a feature branch in the local repository
self._execute_command(
'git --no-pager checkout -b feature-branch', self.local_dir
)
# Setup unstaged changes...
self.write_file(self.local_dir, 'unstaged_modified.txt', ('Line 4',))
self.write_file(self.local_dir, 'unstaged_add.txt')
os.remove(os.path.join(self.local_dir, 'unstaged_delete.txt'))
# Modify a file and create a new file
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
f.write('Modified content')
def setup_nested(self):
nested_1 = Path(self.local_dir, 'nested 1')
nested_1.mkdir()
nested_1 = str(nested_1)
self.run_command('git init --initial-branch=main', nested_1)
self._execute_command("git config user.email 'test@example.com'", nested_1)
self._execute_command("git config user.name 'Test User'", nested_1)
self.write_file(nested_1, 'committed_add.txt')
self.run_command('git add .', nested_1)
self.run_command('git commit -m "Initial Commit"', nested_1)
self.write_file(nested_1, 'staged_add.txt')
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
f.write('New file content')
nested_2 = Path(self.local_dir, 'nested_2')
nested_2.mkdir()
nested_2 = str(nested_2)
self.run_command('git init --initial-branch=main', nested_2)
self._execute_command("git config user.email 'test@example.com'", nested_2)
self._execute_command("git config user.name 'Test User'", nested_2)
self.write_file(nested_2, 'committed_add.txt')
self.run_command('git add .', nested_2)
self.run_command('git commit -m "Initial Commit"', nested_2)
self.write_file(nested_2, 'unstaged_add.txt')
# Add and commit file1.txt changes to create a baseline
self._execute_command('git --no-pager add file1.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Update file1.txt'", self.local_dir
)
# Add and commit file2.txt, then modify it
self._execute_command('git --no-pager add file2.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file2.txt'", self.local_dir
)
# Modify file2.txt and stage it
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
f.write('Modified new file content')
self._execute_command('git --no-pager add file2.txt', self.local_dir)
# Create a file that will be deleted
with open(os.path.join(self.local_dir, 'file3.txt'), 'w') as f:
f.write('File to be deleted')
self._execute_command('git --no-pager add file3.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file3.txt'", self.local_dir
)
self._execute_command('git --no-pager rm file3.txt', self.local_dir)
# Modify file1.txt again but don't stage it (unstaged change)
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
f.write('Modified content again')
# Push the feature branch to origin
self._execute_command(
'git --no-pager push -u origin feature-branch', self.local_dir
)
def test_is_git_repo(self):
"""Test that _is_git_repo returns True for a git repository."""
self.assertTrue(self.git_handler._is_git_repo())
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager rev-parse --is-inside-work-tree'
for cmd, _ in self.executed_commands
)
)
def test_get_current_file_content(self):
"""Test that _get_current_file_content returns the current content of a file."""
content = self.git_handler._get_current_file_content('file1.txt')
self.assertEqual(content.strip(), 'Modified content again')
# Verify the command was executed
self.assertTrue(
any(cmd == 'cat file1.txt' for cmd, _ in self.executed_commands)
)
def test_get_git_changes(self):
"""
Test with unpushed commits, staged commits, and unstaged commits
"""
changes = self.git_handler.get_git_changes()
"""Test that get_git_changes returns the combined list of changed and untracked files."""
# Create an untracked file
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
f.write('Untracked file content')
expected_changes = [
{'status': 'A', 'path': 'committed_add.txt'},
{'status': 'D', 'path': 'committed_delete.txt'},
{'status': 'M', 'path': 'committed_modified.txt'},
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
assert changes == expected_changes
def test_get_git_changes_after_push(self):
"""
Test with staged commits, and unstaged commits
"""
self.run_command('git push -u origin feature-branch', self.local_dir)
changes = self.git_handler.get_git_changes()
expected_changes = [
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
assert changes == expected_changes
def test_get_git_changes_nested_repos(self):
"""
Test with staged commits, and unstaged commits
"""
self.setup_nested()
# Create a new file and stage it
with open(os.path.join(self.local_dir, 'new_file2.txt'), 'w') as f:
f.write('New file 2 content')
self._execute_command('git --no-pager add new_file2.txt', self.local_dir)
changes = self.git_handler.get_git_changes()
self.assertIsNotNone(changes)
expected_changes = [
{'status': 'A', 'path': 'committed_add.txt'},
{'status': 'D', 'path': 'committed_delete.txt'},
{'status': 'M', 'path': 'committed_modified.txt'},
{'status': 'A', 'path': 'nested 1/committed_add.txt'},
{'status': 'A', 'path': 'nested 1/staged_add.txt'},
{'status': 'A', 'path': 'nested_2/committed_add.txt'},
{'status': 'A', 'path': 'nested_2/unstaged_add.txt'},
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
# Should include file1.txt (modified), file3.txt (deleted), new_file2.txt (added), and untracked.txt (untracked)
paths = [change['path'] for change in changes]
self.assertIn('file1.txt', paths)
self.assertIn('file3.txt', paths)
self.assertIn('new_file2.txt', paths)
self.assertIn('untracked.txt', paths)
assert changes == expected_changes
# Check that the changes include both changed and untracked files
statuses = [change['status'] for change in changes]
self.assertIn('M', statuses) # Modified
self.assertIn('A', statuses) # Added
self.assertIn('D', statuses) # Deleted
def test_get_git_diff_staged_modified(self):
"""Test on a staged modified"""
diff = self.git_handler.get_git_diff('staged_modified.txt')
expected_diff = {
'original': 'staged_modified.txt\nLine 1\nLine 2\nLine 3',
'modified': 'staged_modified.txt\nLine 4',
}
assert diff == expected_diff
def test_get_git_changes_multiple_repositories(self):
"""Test that get_git_changes can detect changes in multiple git repositories within a workspace."""
# Create a workspace directory with multiple git repositories
workspace_dir = os.path.join(self.test_dir, 'workspace')
repo1_dir = os.path.join(workspace_dir, 'repo1')
repo2_dir = os.path.join(workspace_dir, 'repo2')
non_git_dir = os.path.join(workspace_dir, 'non_git')
def test_get_git_diff_unchanged(self):
"""Test that get_git_diff delegates to the git_diff module."""
diff = self.git_handler.get_git_diff('unchanged.txt')
expected_diff = {
'original': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
'modified': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
}
assert diff == expected_diff
os.makedirs(workspace_dir, exist_ok=True)
os.makedirs(repo1_dir, exist_ok=True)
os.makedirs(repo2_dir, exist_ok=True)
os.makedirs(non_git_dir, exist_ok=True)
def test_get_git_diff_unpushed(self):
"""Test that get_git_diff delegates to the git_diff module."""
diff = self.git_handler.get_git_diff('committed_modified.txt')
expected_diff = {
'original': 'committed_modified.txt\nLine 1\nLine 2\nLine 3',
'modified': 'committed_modified.txt\nLine 4',
}
assert diff == expected_diff
# Set up repo1
self._execute_command('git --no-pager init', repo1_dir)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", repo1_dir
)
self._execute_command("git --no-pager config user.name 'Test User'", repo1_dir)
with open(os.path.join(repo1_dir, 'repo1_file.txt'), 'w') as f:
f.write('repo1 content')
self._execute_command('git --no-pager add repo1_file.txt', repo1_dir)
self._execute_command("git --no-pager commit -m 'Initial commit'", repo1_dir)
# Modify the file to create changes
with open(os.path.join(repo1_dir, 'repo1_file.txt'), 'w') as f:
f.write('repo1 modified content')
def test_get_git_diff_unstaged_add(self):
"""Test that get_git_diff delegates to the git_diff module."""
diff = self.git_handler.get_git_diff('unstaged_add.txt')
expected_diff = {
'original': '',
'modified': 'unstaged_add.txt\nLine 1\nLine 2\nLine 3',
}
assert diff == expected_diff
# Set up repo2
self._execute_command('git --no-pager init', repo2_dir)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", repo2_dir
)
self._execute_command("git --no-pager config user.name 'Test User'", repo2_dir)
with open(os.path.join(repo2_dir, 'repo2_file.txt'), 'w') as f:
f.write('repo2 content')
self._execute_command('git --no-pager add repo2_file.txt', repo2_dir)
self._execute_command("git --no-pager commit -m 'Initial commit'", repo2_dir)
# Add an untracked file
with open(os.path.join(repo2_dir, 'untracked.txt'), 'w') as f:
f.write('untracked content')
def test_get_git_changes_fallback(self):
"""Test that get_git_changes falls back to creating a script file when needed."""
# Add a file to the non-git directory (should be ignored)
with open(os.path.join(non_git_dir, 'ignored_file.txt'), 'w') as f:
f.write('ignored content')
# Break the git changes command
with patch(
'openhands.runtime.utils.git_handler.GIT_CHANGES_CMD',
'non-existant-command',
):
self.git_handler.git_changes_cmd = git_handler.GIT_CHANGES_CMD
# Create a GitHandler for the workspace directory
workspace_handler = GitHandler(self._execute_command)
workspace_handler.set_cwd(workspace_dir)
changes = self.git_handler.get_git_changes()
# Clear executed commands to start fresh
self.executed_commands = []
expected_changes = [
{'status': 'A', 'path': 'committed_add.txt'},
{'status': 'D', 'path': 'committed_delete.txt'},
{'status': 'M', 'path': 'committed_modified.txt'},
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
# Get changes from all repositories
changes = workspace_handler.get_git_changes()
self.assertIsNotNone(changes)
assert changes == expected_changes
def test_get_git_diff_fallback(self):
"""Test that get_git_diff delegates to the git_diff module."""
# Break the git diff command
with patch(
'openhands.runtime.utils.git_handler.GIT_DIFF_CMD', 'non-existant-command'
):
self.git_handler.git_diff_cmd = git_handler.GIT_DIFF_CMD
diff = self.git_handler.get_git_diff('unchanged.txt')
expected_diff = {
'original': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
'modified': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
}
assert diff == expected_diff
# Should find changes from both repositories
assert len(changes) == 2
assert {'status': 'M', 'path': 'repo1/repo1_file.txt'} in changes
assert {'status': 'A', 'path': 'repo2/untracked.txt'} in changes
+120
View File
@@ -0,0 +1,120 @@
import os
import shutil
import subprocess
import tempfile
import unittest
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
class TestGitHandlerWithRealRepo(unittest.TestCase):
def setUp(self):
# Create temporary directories for our test repositories
self.test_dir = tempfile.mkdtemp()
self.origin_dir = os.path.join(self.test_dir, 'origin')
self.local_dir = os.path.join(self.test_dir, 'local')
# Create the directories
os.makedirs(self.origin_dir, exist_ok=True)
os.makedirs(self.local_dir, exist_ok=True)
# Set up the git repositories
self._setup_git_repos()
# Initialize the GitHandler with a real execute function
self.git_handler = GitHandler(self._execute_command)
self.git_handler.set_cwd(self.local_dir)
def tearDown(self):
# Clean up the temporary directories
shutil.rmtree(self.test_dir)
def _execute_command(self, cmd, cwd=None):
"""Execute a shell command and return the result."""
try:
result = subprocess.run(
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
)
return CommandResult(result.stdout, result.returncode)
except Exception as e:
return CommandResult(str(e), 1)
def _setup_git_repos(self):
"""Set up real git repositories for testing."""
# Set up origin repository
self._execute_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
"git config user.email 'test@example.com'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Create a file and commit it
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
f.write('Original content')
self._execute_command('git add file1.txt', self.origin_dir)
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
# Clone the origin repository to local
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
self._execute_command(
"git config user.email 'test@example.com'", self.local_dir
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
# Create a feature branch in the local repository
self._execute_command('git checkout -b feature-branch', self.local_dir)
# Modify a file and create a new file
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
f.write('Modified content')
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
f.write('New file content')
# Add the new file but don't commit anything yet
self._execute_command('git add file2.txt', self.local_dir)
def test_is_git_repo(self):
"""Test that _is_git_repo returns True for a git repository."""
self.assertTrue(self.git_handler._is_git_repo())
def test_get_ref_content(self):
"""Test that _get_ref_content returns the content from a valid ref."""
# First commit the changes to make sure we have a valid ref
self._execute_command('git add file1.txt', self.local_dir)
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
# Get the content of file1.txt from the main branch
content = self.git_handler._get_ref_content('file1.txt')
self.assertEqual(content.strip(), 'Original content')
def test_get_current_file_content(self):
"""Test that _get_current_file_content returns the current content of a file."""
content = self.git_handler._get_current_file_content('file1.txt')
self.assertEqual(content.strip(), 'Modified content')
def test_get_git_changes(self):
"""Test that get_git_changes returns the combined list of changed and untracked files."""
# Create an untracked file
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
f.write('Untracked file content')
changes = self.git_handler.get_git_changes()
self.assertIsNotNone(changes)
# Should include file1.txt (modified), file2.txt (added), and untracked.txt (untracked)
paths = [change['path'] for change in changes]
self.assertIn('file1.txt', paths)
self.assertIn('file2.txt', paths)
self.assertIn('untracked.txt', paths)
def test_get_git_diff(self):
"""Test that get_git_diff returns the original and modified content of a file."""
diff = self.git_handler.get_git_diff('file1.txt')
self.assertEqual(diff['modified'].strip(), 'Modified content')
self.assertEqual(diff['original'].strip(), 'Original content')
if __name__ == '__main__':
unittest.main()

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