mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c92f71c8e2 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+1403
-1178
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
+26
-76
@@ -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}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
# ================================================
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -190,7 +190,6 @@ VERIFIED_OPENHANDS_MODELS = [
|
||||
'o4-mini',
|
||||
'gemini-2.5-pro',
|
||||
'kimi-k2-0711-preview',
|
||||
'qwen3-coder-480b',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)}))
|
||||
@@ -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))
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = "*"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user