Merge branch 'main' into migrate-org-db-litellm-from-deploy

This commit is contained in:
Chuck Butkus
2025-11-12 23:08:34 -05:00
48 changed files with 1342 additions and 263 deletions

View File

@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.61-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik`
## Develop inside Docker container

View File

@@ -82,17 +82,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.openhands.dev/openhands/runtime:0.61-nikolaik
docker pull docker.openhands.dev/openhands/runtime:0.62-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.61-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.62-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.openhands.dev/openhands/openhands:0.61
docker.openhands.dev/openhands/openhands:0.62
```
</details>

View File

@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.61-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.62-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.61-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.62-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -5,12 +5,8 @@ from experiments.constants import (
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
)
from experiments.experiment_versions import (
handle_condenser_max_step_experiment,
handle_system_prompt_experiment,
)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment__v1,
)
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
@@ -31,10 +27,6 @@ class SaaSExperimentManager(ExperimentManager):
)
return agent
agent = handle_condenser_max_step_experiment__v1(
user_id, conversation_id, agent
)
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
@@ -60,20 +52,7 @@ class SaaSExperimentManager(ExperimentManager):
"""
logger.debug(
'experiment_manager:run_conversation_variant_test:started',
extra={'user_id': user_id},
)
# Skip all experiment processing if the experiment manager is disabled
if not ENABLE_EXPERIMENT_MANAGER:
logger.info(
'experiment_manager:run_conversation_variant_test:skipped',
extra={'reason': 'experiment_manager_disabled'},
)
return conversation_settings
# Apply conversation-scoped experiments
conversation_settings = handle_condenser_max_step_experiment(
user_id, conversation_id, conversation_settings
extra={'user_id': user_id, 'conversation_id': conversation_id},
)
return conversation_settings

44
enterprise/poetry.lock generated
View File

@@ -483,7 +483,7 @@ description = "LTS Port of Python audioop"
optional = false
python-versions = ">=3.13"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800"},
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303"},
@@ -1743,8 +1743,8 @@ files = [
[package.dependencies]
bytecode = [
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
{version = ">=0.15.1", markers = "python_version ~= \"3.12.0\""},
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
]
envier = ">=0.6.1,<0.7.0"
legacy-cgi = {version = ">=2.0.0", markers = "python_version >= \"3.13.0\""}
@@ -2758,8 +2758,8 @@ googleapis-common-protos = ">=1.56.2,<2.0.0"
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
requests = ">=2.18.0,<3.0.0"
@@ -2871,8 +2871,8 @@ google-auth = ">=2.14.1,<3.0.0"
google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0"
google-cloud-resource-manager = ">=1.3.3,<3.0.0"
google-cloud-storage = [
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.32.0,<4.0.0", markers = "python_version < \"3.13\""},
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
]
google-genai = ">=1.37.0,<2.0.0"
packaging = ">=14.3"
@@ -2981,8 +2981,8 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
grpcio = ">=1.33.2,<2.0.0"
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
@@ -4574,7 +4574,7 @@ description = "Fork of the standard library cgi and cgitb modules removed in Pyt
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd"},
{file = "legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577"},
@@ -6040,14 +6040,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.0.0a6"
version = "1.1.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.0.0a6-py3-none-any.whl", hash = "sha256:72b0da038ede018c55c64f0ac99bc5d991af173627efc63de87d54b3cd69134c"},
{file = "openhands_agent_server-1.0.0a6.tar.gz", hash = "sha256:8c6fbceb07990e3caf7f8797082d1bb614b9f7339bd00576c24fd34a956a03b4"},
{file = "openhands_agent_server-1.1.0-py3-none-any.whl", hash = "sha256:59a856883df23488c0723e47655ef21649a321fcd4709a25a4690866eff6ac88"},
{file = "openhands_agent_server-1.1.0.tar.gz", hash = "sha256:e39bebd39afd45cfcfd765005e7c4e5409e46678bd7612ae20bae79f7057b935"},
]
[package.dependencies]
@@ -6063,7 +6063,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "0.61.0"
version = "0.62.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -6104,9 +6104,9 @@ memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
openhands-aci = "0.3.2"
openhands-agent-server = "1.0.0a6"
openhands-sdk = "1.0.0a6"
openhands-tools = "1.0.0a6"
openhands-agent-server = "1.1.0"
openhands-sdk = "1.1.0"
openhands-tools = "1.1.0"
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -6162,14 +6162,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.0.0a6"
version = "1.1.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.0.0a6-py3-none-any.whl", hash = "sha256:0b0b579fc48a5b7eaa418ca66188206ba00f4d883997bc29291bc1745e0b7ddc"},
{file = "openhands_sdk-1.0.0a6.tar.gz", hash = "sha256:01daff435c5f94037b9b4ba85054097ca6235982a9b0fee00341279d4c4b5a01"},
{file = "openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e"},
{file = "openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c"},
]
[package.dependencies]
@@ -6188,14 +6188,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.0.0a6"
version = "1.1.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.0.0a6-py3-none-any.whl", hash = "sha256:55b75016f7e3930e4365393a026726eeffae027363d03862a17a8cebc1aed670"},
{file = "openhands_tools-1.0.0a6.tar.gz", hash = "sha256:4d5382f3e1cab9d23c1ef7ea8e36e821083886d6d4b019100cbf897e3b0cd3be"},
{file = "openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9"},
{file = "openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6"},
]
[package.dependencies]
@@ -6269,8 +6269,8 @@ files = [
[package.dependencies]
googleapis-common-protos = ">=1.57,<2.0"
grpcio = [
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
]
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.38.0"
@@ -13367,7 +13367,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
@@ -13384,7 +13384,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},

View File

@@ -32,6 +32,7 @@ from openhands.server.services.conversation_service import create_provider_token
from openhands.server.shared import config
from openhands.server.user_auth import get_access_token
from openhands.server.user_auth.user_auth import get_user_auth
from openhands.utils.posthog_tracker import track_user_signup_completed
with warnings.catch_warnings():
warnings.simplefilter('ignore')
@@ -376,6 +377,12 @@ async def accept_tos(request: Request):
logger.info(f'User {user_id} accepted TOS')
# Track user signup completion in PostHog
track_user_signup_completed(
user_id=user_id,
signup_timestamp=user_settings.accepted_tos.isoformat(),
)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
)

View File

@@ -18,6 +18,7 @@ from storage.lite_llm_manager import LiteLlmManager
from storage.user_store import UserStore
from openhands.server.user_auth import get_user_id
from openhands.utils.posthog_tracker import track_credits_purchased
stripe.api_key = STRIPE_API_KEY
billing_router = APIRouter(prefix='/api/billing')
@@ -246,6 +247,20 @@ async def success_callback(session_id: str, request: Request):
)
session.commit()
# Track credits purchased in PostHog
try:
track_credits_purchased(
user_id=billing_session.user_id,
amount_usd=amount_subtotal / 100, # Convert cents to dollars
credits_added=add_credits,
stripe_session_id=session_id,
)
except Exception as e:
logger.warning(
f'Failed to track credits purchase: {e}',
extra={'user_id': billing_session.user_id, 'error': str(e)},
)
return RedirectResponse(
f'{request.base_url}settings/billing?checkout=success', status_code=302
)

View File

@@ -92,11 +92,8 @@ def test_unknown_variant_returns_original_agent_without_changes(monkeypatch):
assert getattr(result, 'condenser', None) is None
@patch('experiments.experiment_manager.handle_condenser_max_step_experiment__v1')
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', False)
def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
mock_handle_condenser,
):
def test_run_agent_variant_tests_v1_noop_when_manager_disabled():
"""If ENABLE_EXPERIMENT_MANAGER is False, the method returns the exact same agent and does not call the handler."""
agent = make_agent()
conv_id = uuid4()
@@ -109,8 +106,6 @@ def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
# Same object returned (no copy)
assert result is agent
# Handler should not have been called
mock_handle_condenser.assert_not_called()
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
@@ -131,7 +126,3 @@ def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeyp
# Should be a different instance than the original (copied after handler runs)
assert result is not agent
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
# The condenser returned by the handler must be preserved after the system-prompt override copy
assert isinstance(result.condenser, LLMSummarizingCondenser)
assert result.condenser.max_size == 80

View File

@@ -8,10 +8,11 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"TASK_TRACKING_OBSERVATION$TASK_LIST": "Task List",
"TASK_TRACKING_OBSERVATION$TASK_ID": "ID",
"TASK_TRACKING_OBSERVATION$TASK_NOTES": "Notes",
"TASK_TRACKING_OBSERVATION$RESULT": "Result",
TASK_TRACKING_OBSERVATION$TASK_LIST: "Task List",
TASK_TRACKING_OBSERVATION$TASK_ID: "ID",
TASK_TRACKING_OBSERVATION$TASK_NOTES: "Notes",
TASK_TRACKING_OBSERVATION$RESULT: "Result",
COMMON$TASKS: "Tasks",
};
return translations[key] || key;
},
@@ -61,19 +62,26 @@ describe("TaskTrackingObservationContent", () => {
it("renders task list when command is 'plan' and tasks exist", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
expect(screen.getByText("Task List (3 items)")).toBeInTheDocument();
expect(screen.getByText("Tasks")).toBeInTheDocument();
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
});
it("displays correct status icons and badges", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
const { container } = render(
<TaskTrackingObservationContent event={mockEvent} />,
);
// Check for status text (the icons are emojis)
expect(screen.getByText("todo")).toBeInTheDocument();
expect(screen.getByText("in progress")).toBeInTheDocument();
expect(screen.getByText("done")).toBeInTheDocument();
// Status is represented by icons, not text. Verify task items are rendered with their titles
// which indicates the status icons are present (status affects icon rendering)
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
// Verify task items are present (they contain the status icons)
const taskItems = container.querySelectorAll('[data-name="item"]');
expect(taskItems).toHaveLength(3);
});
it("displays task IDs and notes", () => {
@@ -84,14 +92,9 @@ describe("TaskTrackingObservationContent", () => {
expect(screen.getByText("ID: task-3")).toBeInTheDocument();
expect(screen.getByText("Notes: This is a test task")).toBeInTheDocument();
expect(screen.getByText("Notes: Completed successfully")).toBeInTheDocument();
});
it("renders result section when content exists", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
expect(screen.getByText("Result")).toBeInTheDocument();
expect(screen.getByText("Task tracking operation completed successfully")).toBeInTheDocument();
expect(
screen.getByText("Notes: Completed successfully"),
).toBeInTheDocument();
});
it("does not render task list when command is not 'plan'", () => {
@@ -105,7 +108,7 @@ describe("TaskTrackingObservationContent", () => {
render(<TaskTrackingObservationContent event={eventWithoutPlan} />);
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
});
it("does not render task list when task list is empty", () => {
@@ -119,17 +122,6 @@ describe("TaskTrackingObservationContent", () => {
render(<TaskTrackingObservationContent event={eventWithEmptyTasks} />);
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
});
it("does not render result section when content is empty", () => {
const eventWithoutContent = {
...mockEvent,
content: "",
};
render(<TaskTrackingObservationContent event={eventWithoutContent} />);
expect(screen.queryByText("Result")).not.toBeInTheDocument();
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
});
});

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.62.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.62.0",
"dependencies": {
"@heroui/react": "^2.8.4",
"@heroui/use-infinite-scroll": "^2.2.11",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.62.0",
"private": true,
"type": "module",
"engines": {

View File

@@ -1,11 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { OpenHandsObservation } from "#/types/core/observations";
import { isTaskTrackingObservation } from "#/types/core/guards";
import { GenericEventMessage } from "../generic-event-message";
import { TaskTrackingObservationContent } from "../task-tracking-observation-content";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
import { getObservationResult } from "../event-content-helpers/get-observation-result";
interface TaskTrackingEventMessageProps {
event: OpenHandsObservation;
@@ -16,34 +12,13 @@ export function TaskTrackingEventMessage({
event,
shouldShowConfirmationButtons,
}: TaskTrackingEventMessageProps) {
const { t } = useTranslation();
if (!isTaskTrackingObservation(event)) {
return null;
}
const { command } = event.extras;
let title: React.ReactNode;
let initiallyExpanded = false;
// Determine title and expansion state based on command
if (command === "plan") {
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_PLAN");
initiallyExpanded = true;
} else {
// command === "view"
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_VIEW");
initiallyExpanded = false;
}
return (
<div>
<GenericEventMessage
title={title}
details={<TaskTrackingObservationContent event={event} />}
success={getObservationResult(event)}
initiallyExpanded={initiallyExpanded}
/>
<TaskTrackingObservationContent event={event} />
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
);

View File

@@ -1,6 +1,5 @@
import { TaskTrackingObservation } from "#/types/core/observations";
import { TaskListSection } from "./task-tracking/task-list-section";
import { ResultSection } from "./task-tracking/result-section";
interface TaskTrackingObservationContentProps {
event: TaskTrackingObservation;
@@ -16,11 +15,6 @@ export function TaskTrackingObservationContent({
<div className="flex flex-col gap-4">
{/* Task List section - only show for 'plan' command */}
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
{/* Result message - only show if there's meaningful content */}
{event.content && event.content.trim() && (
<ResultSection content={event.content} />
)}
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
interface ResultSectionProps {
content: string;
}
export function ResultSection({ content }: ResultSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Typography.H3>{t("TASK_TRACKING_OBSERVATION$RESULT")}</Typography.H3>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
<pre className="whitespace-pre-wrap text-sm">{content.trim()}</pre>
</div>
</div>
);
}

View File

@@ -1,7 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import CircleIcon from "#/icons/u-circle.svg?react";
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
import LoadingIcon from "#/icons/loading.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
import { StatusIcon } from "./status-icon";
import { StatusBadge } from "./status-badge";
interface TaskItemProps {
task: {
@@ -10,33 +14,47 @@ interface TaskItemProps {
status: "todo" | "in_progress" | "done";
notes?: string;
};
index: number;
}
export function TaskItem({ task, index }: TaskItemProps) {
export function TaskItem({ task }: TaskItemProps) {
const { t } = useTranslation();
const icon = useMemo(() => {
switch (task.status) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return <LoadingIcon className="w-4 h-4 text-[#ffffff]" />;
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
}
}, [task.status]);
const isDoneStatus = task.status === "done";
return (
<div className="border-l-2 border-gray-600 pl-3">
<div className="flex items-start gap-2">
<StatusIcon status={task.status} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Typography.Text className="text-sm text-gray-400">
{index + 1}.
</Typography.Text>
<StatusBadge status={task.status} />
</div>
<h4 className="font-medium text-white mb-1">{task.title}</h4>
<Typography.Text className="text-xs text-gray-400 mb-1">
{t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id}
</Typography.Text>
{task.notes && (
<Typography.Text className="text-sm text-gray-300 italic">
{t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}: {task.notes}
</Typography.Text>
<div
className="flex gap-[14px] items-center px-4 py-2 w-full"
data-name="item"
>
<div className="shrink-0">{icon}</div>
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
<Typography.Text
className={cn(
"text-[12px] text-white",
isDoneStatus && "text-[#A3A3A3]",
)}
</div>
>
{task.title}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3] font-normal">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3]">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
</Typography.Text>
</div>
</div>
);

View File

@@ -1,5 +1,7 @@
import { useTranslation } from "react-i18next";
import { TaskItem } from "./task-item";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
interface TaskListSectionProps {
@@ -15,19 +17,20 @@ export function TaskListSection({ taskList }: TaskListSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Typography.H3>
{t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "}
{taskList.length === 1 ? "item" : "items"})
</Typography.H3>
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
{/* Header Tabs */}
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
{t(I18nKey.COMMON$TASKS)}
</Typography.Text>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<div className="space-y-3">
{taskList.map((task, index) => (
<TaskItem key={task.id} task={task} index={index} />
))}
</div>
{/* Task Items */}
<div>
{taskList.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
</div>
);

View File

@@ -1,10 +1,13 @@
import { Trans } from "react-i18next";
import { OpenHandsEvent } from "#/types/v1/core";
import React from "react";
import { OpenHandsEvent, ObservationEvent } from "#/types/v1/core";
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
import { MonoComponent } from "../../../features/chat/mono-component";
import { PathComponent } from "../../../features/chat/path-component";
import { getActionContent } from "./get-action-content";
import { getObservationContent } from "./get-observation-content";
import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content";
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
import i18n from "#/i18n";
const trimText = (text: string, maxLength: number): string => {
@@ -158,14 +161,24 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
export const getEventContent = (event: OpenHandsEvent) => {
let title: React.ReactNode = "";
let details: string = "";
let details: string | React.ReactNode = "";
if (isActionEvent(event)) {
title = getActionEventTitle(event);
details = getActionContent(event);
} else if (isObservationEvent(event)) {
title = getObservationEventTitle(event);
details = getObservationContent(event);
// For TaskTrackerObservation, use React component instead of markdown
if (event.observation.kind === "TaskTrackerObservation") {
details = (
<TaskTrackingObservationContent
event={event as ObservationEvent<TaskTrackerObservation>}
/>
);
} else {
details = getObservationContent(event);
}
}
return {

View File

@@ -49,6 +49,10 @@ const getExecuteBashObservationContent = (
let { output } = observation;
if (!output) {
output = "";
}
if (output.length > MAX_CONTENT_LENGTH) {
output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
}
@@ -136,6 +140,7 @@ const getTaskTrackerObservationContent = (
if (
"content" in observation &&
observation.content &&
typeof observation.content === "string" &&
observation.content.trim()
) {
content += `\n\n**Result:** ${observation.content.trim()}`;

View File

@@ -27,13 +27,16 @@ export function FinishEventMessage({
microagentPRUrl,
actions,
}: FinishEventMessageProps) {
const eventContent = getEventContent(event);
// For FinishAction, details is always a string (getActionContent returns string)
const message =
typeof eventContent.details === "string"
? eventContent.details
: String(eventContent.details);
return (
<>
<ChatMessage
type="agent"
message={getEventContent(event).details}
actions={actions}
/>
<ChatMessage type="agent" message={message} actions={actions} />
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}

View File

@@ -16,6 +16,13 @@ export function GenericEventMessageWrapper({
}: GenericEventMessageWrapperProps) {
const { title, details } = getEventContent(event);
if (
isObservationEvent(event) &&
event.observation.kind === "TaskTrackerObservation"
) {
return <div>{details}</div>;
}
return (
<div>
<GenericEventMessage

View File

@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
import CircleIcon from "#/icons/u-circle.svg?react";
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
import LoadingIcon from "#/icons/loading.svg?react";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
interface TaskItemProps {
task: TaskItemType;
}
export function TaskItem({ task }: TaskItemProps) {
const { t } = useTranslation();
const icon = useMemo(() => {
switch (task.status) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return (
<LoadingIcon className="w-4 h-4 text-[#ffffff]" strokeWidth={0.5} />
);
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
}
}, [task.status]);
const isDoneStatus = task.status === "done";
return (
<div
className="flex gap-[14px] items-center px-4 py-2 w-full"
data-name="item"
>
<div className="shrink-0">{icon}</div>
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
<Typography.Text
className={cn(
"text-[12px] text-white",
isDoneStatus && "text-[#A3A3A3]",
)}
>
{task.title}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3]">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
</Typography.Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { TaskItem } from "./task-item";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
interface TaskListSectionProps {
taskList: TaskItemType[];
}
export function TaskListSection({ taskList }: TaskListSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
{/* Header Tabs */}
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
{t(I18nKey.COMMON$TASKS)}
</Typography.Text>
</div>
{/* Task Items */}
<div>
{taskList.map((task, index) => (
<TaskItem key={`task-${index}`} task={task} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import { ObservationEvent } from "#/types/v1/core";
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
import { TaskListSection } from "./task-list-section";
interface TaskTrackingObservationContentProps {
event: ObservationEvent<TaskTrackerObservation>;
}
export function TaskTrackingObservationContent({
event,
}: TaskTrackingObservationContentProps): React.ReactNode {
const { observation } = event;
const { command, task_list: taskList } = observation;
const shouldShowTaskList = command === "plan" && taskList.length > 0;
return (
<div className="flex flex-col gap-4">
{/* Task List section - only show for 'plan' command */}
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
</div>
);
}

View File

@@ -142,6 +142,7 @@ export function WsClientProvider({
const { addEvent, clearEvents } = useEventStore();
const queryClient = useQueryClient();
const sioRef = React.useRef<Socket | null>(null);
const pendingEventsRef = React.useRef<Record<string, unknown>[]>([]);
const [webSocketStatus, setWebSocketStatus] =
React.useState<V0_WebSocketStatus>("DISCONNECTED");
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
@@ -151,17 +152,37 @@ export function WsClientProvider({
const { data: conversation, refetch: refetchConversation } =
useActiveConversation();
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
EventLogger.error("WebSocket is not connected.");
function flushPendingEvents(socket: Socket | null = sioRef.current) {
if (!socket || pendingEventsRef.current.length === 0) {
return;
}
sioRef.current.emit("oh_user_action", event);
pendingEventsRef.current.forEach((queuedEvent) => {
socket.emit("oh_user_action", queuedEvent);
});
pendingEventsRef.current = [];
}
function send(event: Record<string, unknown>) {
const socket = sioRef.current;
if (!socket) {
EventLogger.error("WebSocket is not connected, queuing message...");
pendingEventsRef.current.push(event);
return;
}
if (pendingEventsRef.current.length > 0) {
flushPendingEvents(socket);
}
socket.emit("oh_user_action", event);
}
function handleConnect() {
setWebSocketStatus("CONNECTED");
removeErrorMessage();
flushPendingEvents();
}
function handleMessage(event: Record<string, unknown>) {
@@ -292,6 +313,7 @@ export function WsClientProvider({
clearEvents();
setWebSocketStatus("CONNECTING");
pendingEventsRef.current = [];
}, [conversationId]);
React.useEffect(() => {
@@ -301,6 +323,12 @@ export function WsClientProvider({
// Clear error messages when conversation is intentionally stopped
if (conversation && conversation.status === "STOPPED") {
const existingSocket = sioRef.current;
if (existingSocket) {
existingSocket.disconnect();
}
sioRef.current = null;
pendingEventsRef.current = [];
removeErrorMessage();
setWebSocketStatus("DISCONNECTED");
return () => undefined; // conversation intentionally stopped
@@ -320,6 +348,10 @@ export function WsClientProvider({
!conversation.runtime_status ||
conversation.runtime_status === "STATUS$STOPPED"
) {
if (sioRef.current) {
sioRef.current.disconnect();
}
sioRef.current = null;
return () => undefined; // conversation not ready for WebSocket connection
}
@@ -368,6 +400,7 @@ export function WsClientProvider({
sio.on("disconnect", handleDisconnect);
sioRef.current = sio;
flushPendingEvents(sio);
return () => {
sio.off("connect", handleConnect);

View File

@@ -22,7 +22,7 @@ const renderCommand = (
return;
}
const trimmedContent = content.replaceAll("\n", "\r\n").trim();
const trimmedContent = (content || "").replaceAll("\n", "\r\n").trim();
// Only write if there's actual content to avoid empty newlines
if (trimmedContent) {
terminal.writeln(parseTerminalOutput(trimmedContent));

View File

@@ -937,6 +937,7 @@ export enum I18nKey {
AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION",
COMMON$MORE_OPTIONS = "COMMON$MORE_OPTIONS",
COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN",
COMMON$TASKS = "COMMON$TASKS",
COMMON$PLAN_MD = "COMMON$PLAN_MD",
COMMON$READ_MORE = "COMMON$READ_MORE",
COMMON$BUILD = "COMMON$BUILD",

View File

@@ -14991,6 +14991,22 @@
"de": "Einen Plan erstellen",
"uk": "Створити план"
},
"COMMON$TASKS": {
"en": "Tasks",
"ja": "タスク",
"zh-CN": "任务",
"zh-TW": "任務",
"ko-KR": "작업",
"no": "Oppgaver",
"it": "Attività",
"pt": "Tarefas",
"es": "Tareas",
"ar": "مهام",
"fr": "Tâches",
"tr": "Görevler",
"de": "Aufgaben",
"uk": "Завдання"
},
"COMMON$PLAN_MD": {
"en": "Plan.md",
"ja": "Plan.md",

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="7" viewBox="0 0 16 7" fill="none">
<path d="M7.50684 0.25C9.24918 0.25 10.9332 0.87774 12.251 2.01758C13.5688 3.15746 14.4327 4.73379 14.6836 6.45801L14.7256 6.74316H13.2129L13.1777 6.53516C12.9499 5.19635 12.2554 3.98161 11.2178 3.10547C10.1799 2.22925 8.86511 1.74805 7.50684 1.74805C6.14866 1.74811 4.83466 2.22931 3.79688 3.10547C2.75913 3.98161 2.06476 5.19628 1.83691 6.53516L1.80078 6.74316H0.289063L0.331055 6.45801C0.581982 4.73389 1.44504 3.15745 2.7627 2.01758C4.08041 0.877757 5.76455 0.250069 7.50684 0.25Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M14.72 8.79L10.43 13.09L8.78 11.44C8.69036 11.3353 8.58004 11.2503 8.45597 11.1903C8.33191 11.1303 8.19678 11.0965 8.05906 11.0912C7.92134 11.0859 7.78401 11.1091 7.65568 11.1594C7.52736 11.2096 7.41081 11.2859 7.31335 11.3833C7.2159 11.4808 7.13964 11.5974 7.08937 11.7257C7.03909 11.854 7.01589 11.9913 7.02121 12.1291C7.02653 12.2668 7.06026 12.4019 7.12028 12.526C7.1803 12.65 7.26532 12.7604 7.37 12.85L9.72 15.21C9.81344 15.3027 9.92426 15.376 10.0461 15.4258C10.1679 15.4755 10.2984 15.5008 10.43 15.5C10.6923 15.4989 10.9437 15.3947 11.13 15.21L16.13 10.21C16.2237 10.117 16.2981 10.0064 16.3489 9.88458C16.3997 9.76272 16.4258 9.63201 16.4258 9.5C16.4258 9.36799 16.3997 9.23728 16.3489 9.11542C16.2981 8.99356 16.2237 8.88296 16.13 8.79C15.9426 8.60375 15.6892 8.49921 15.425 8.49921C15.1608 8.49921 14.9074 8.60375 14.72 8.79ZM12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7363 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM12 20C10.4178 20 8.87104 19.5308 7.55544 18.6518C6.23985 17.7727 5.21447 16.5233 4.60897 15.0615C4.00347 13.5997 3.84504 11.9911 4.15372 10.4393C4.4624 8.88743 5.22433 7.46197 6.34315 6.34315C7.46197 5.22433 8.88743 4.4624 10.4393 4.15372C11.9911 3.84504 13.5997 4.00346 15.0615 4.60896C16.5233 5.21447 17.7727 6.23984 18.6518 7.55544C19.5308 8.87103 20 10.4177 20 12C20 14.1217 19.1572 16.1566 17.6569 17.6569C16.1566 19.1571 14.1217 20 12 20Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7363 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM12 20C10.4178 20 8.87104 19.5308 7.55544 18.6518C6.23985 17.7727 5.21447 16.5233 4.60897 15.0615C4.00347 13.5997 3.84504 11.9911 4.15372 10.4393C4.4624 8.88743 5.22433 7.46197 6.34315 6.34315C7.46197 5.22433 8.88743 4.4624 10.4393 4.15372C11.9911 3.84504 13.5997 4.00346 15.0615 4.60896C16.5233 5.21447 17.7727 6.23984 18.6518 7.55544C19.5308 8.87103 20 10.4177 20 12C20 14.1217 19.1572 16.1566 17.6569 17.6569C16.1566 19.1571 14.1217 20 12 20Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,6 +1,7 @@
import uuid
from openhands.sdk.conversation import visualizer
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace
@@ -74,11 +75,7 @@ def setup_conversation(
agent = load_agent_specs(str(conversation_id))
if not include_security_analyzer:
# Remove security analyzer from agent spec
agent = agent.model_copy(
update={"security_analyzer": None}
)
# Create conversation - agent context is now set in AgentStore.load()
conversation: BaseConversation = Conversation(
@@ -90,7 +87,11 @@ def setup_conversation(
visualizer=CLIVisualizer
)
if include_security_analyzer:
# Security analyzer is set though conversation API now
if not include_security_analyzer:
conversation.set_security_analyzer(None)
else:
conversation.set_security_analyzer(LLMSecurityAnalyzer())
conversation.set_confirmation_policy(AlwaysConfirm())
print_formatted_text(

View File

@@ -38,6 +38,16 @@ class AgentStore:
str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
agent = Agent.model_validate_json(str_spec)
# Temporary to remove security analyzer from agent specs
# Security analyzer is set via conversation API now
# Doing this so that deprecation warning is thrown only the first time running CLI
if agent.security_analyzer:
agent = agent.model_copy(
update={"security_analyzer": None}
)
self.save(agent)
# Update tools with most recent working directory
updated_tools = get_default_tools(enable_browser=False)

View File

@@ -2,7 +2,6 @@
import os
from typing import Any
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.tools.preset import get_default_agent
from openhands.sdk import LLM
@@ -67,10 +66,4 @@ def get_default_cli_agent(
cli_mode=True
)
agent = agent.model_copy(
update={
'security_analyzer': LLMSecurityAnalyzer()
}
)
return agent

View File

@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
[project]
name = "openhands"
version = "1.0.6"
version = "1.0.7"
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
readme = "README.md"
license = { text = "MIT" }
@@ -18,8 +18,8 @@ classifiers = [
# Using Git URLs for dependencies so installs from PyPI pull from GitHub
# TODO: pin package versions once agent-sdk has published PyPI packages
dependencies = [
"openhands-sdk==1",
"openhands-tools==1",
"openhands-sdk==1.1",
"openhands-tools==1.1",
"prompt-toolkit>=3",
"typer>=0.17.4",
]
@@ -102,5 +102,5 @@ ignore_missing_imports = true
# UNCOMMENT TO USE EXACT COMMIT FROM AGENT-SDK
# [tool.uv.sources]
# openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "aaa0066ee078688e015fcad590393fe6771c10a1" }
# openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "aaa0066ee078688e015fcad590393fe6771c10a1" }
# openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "7b695dc519084e75c482b34473e714845d6cef92" }
# openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "7b695dc519084e75c482b34473e714845d6cef92" }

View File

@@ -1,15 +1,16 @@
"""Test that first-time settings screen usage creates a default agent with security analyzer."""
"""Test that first-time settings screen usage creates a default agent and conversation with security analyzer."""
from unittest.mock import patch
import pytest
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from openhands_cli.user_actions.settings_action import SettingsType
from openhands.sdk import LLM
from openhands.sdk import LLM, Conversation, Workspace
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from pydantic import SecretStr
def test_first_time_settings_creates_default_agent_with_security_analyzer():
"""Test that using the settings screen for the first time creates a default agent with a non-None security analyzer."""
def test_first_time_settings_creates_default_agent_and_conversation_with_security_analyzer():
"""Test that using the settings screen for the first time creates a default agent and conversation with security analyzer."""
# Create a settings screen instance (no conversation initially)
screen = SettingsScreen(conversation=None)
@@ -50,17 +51,20 @@ def test_first_time_settings_creates_default_agent_with_security_analyzer():
assert saved_agent.llm.model == 'openai/gpt-4o-mini', f"Expected model 'openai/gpt-4o-mini', got '{saved_agent.llm.model}'"
assert saved_agent.llm.api_key.get_secret_value() == 'sk-test-key-123', "API key should match the provided value"
# Verify that the agent has a security analyzer and it's not None
assert hasattr(saved_agent, 'security_analyzer'), "Agent should have a security_analyzer attribute"
assert saved_agent.security_analyzer is not None, "Security analyzer should not be None"
# Test that a conversation can be created with the agent and security analyzer can be set
conversation = Conversation(agent=saved_agent, workspace=Workspace(working_dir='/tmp'))
# Verify the security analyzer has the expected type/kind
assert hasattr(saved_agent.security_analyzer, 'kind'), "Security analyzer should have a 'kind' attribute"
assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', f"Expected security analyzer kind 'LLMSecurityAnalyzer', got '{saved_agent.security_analyzer.kind}'"
# Set security analyzer using the new API
security_analyzer = LLMSecurityAnalyzer()
conversation.set_security_analyzer(security_analyzer)
# Verify that the security analyzer was set correctly
assert conversation.state.security_analyzer is not None, "Conversation should have a security analyzer"
assert conversation.state.security_analyzer.kind == 'LLMSecurityAnalyzer', f"Expected security analyzer kind 'LLMSecurityAnalyzer', got '{conversation.state.security_analyzer.kind}'"
def test_first_time_settings_with_advanced_configuration():
"""Test that advanced settings also create a default agent with security analyzer."""
"""Test that advanced settings also create a default agent and conversation with security analyzer."""
screen = SettingsScreen(conversation=None)
@@ -94,11 +98,20 @@ def test_first_time_settings_with_advanced_configuration():
saved_agent = screen.agent_store.load()
# Verify agent creation and security analyzer
# Verify agent creation
assert saved_agent is not None, "Agent should be created with advanced settings"
assert saved_agent.security_analyzer is not None, "Security analyzer should not be None in advanced settings"
assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', "Security analyzer should be LLMSecurityAnalyzer"
# Verify advanced settings were applied
assert saved_agent.llm.model == 'anthropic/claude-3-5-sonnet', "Custom model should be set"
assert saved_agent.llm.base_url == 'https://api.anthropic.com', "Base URL should be set"
assert saved_agent.llm.base_url == 'https://api.anthropic.com', "Base URL should be set"
# Test that a conversation can be created with the agent and security analyzer can be set
conversation = Conversation(agent=saved_agent, workspace=Workspace(working_dir='/tmp'))
# Set security analyzer using the new API
security_analyzer = LLMSecurityAnalyzer()
conversation.set_security_analyzer(security_analyzer)
# Verify that the security analyzer was set correctly
assert conversation.state.security_analyzer is not None, "Conversation should have a security analyzer"
assert conversation.state.security_analyzer.kind == 'LLMSecurityAnalyzer', "Security analyzer should be LLMSecurityAnalyzer"

View File

@@ -108,15 +108,15 @@ class TestConversationRunner:
3. If not paused, we should still ask for confirmation on actions
4. If deferred no run call to agent should be made
5. If accepted, run call to agent should be made
"""
if final_status == ConversationExecutionStatus.FINISHED:
agent.finish_on_step = 1
# Add a mock security analyzer to enable confirmation mode
agent.security_analyzer = MagicMock()
convo = Conversation(agent)
# Set security analyzer using the new API to enable confirmation mode
convo.set_security_analyzer(MagicMock())
convo.state.execution_status = (
ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
)
@@ -127,6 +127,7 @@ class TestConversationRunner:
cr, '_handle_confirmation_request', return_value=confirmation
) as mock_confirmation_request:
cr.process_message(message=None)
mock_confirmation_request.assert_called_once()
assert agent.step_count == expected_run_calls
assert convo.state.execution_status == final_status

18
openhands-cli/uv.lock generated
View File

@@ -1902,7 +1902,7 @@ wheels = [
[[package]]
name = "openhands"
version = "1.0.6"
version = "1.0.7"
source = { editable = "." }
dependencies = [
{ name = "openhands-sdk" },
@@ -1929,8 +1929,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "openhands-sdk", specifier = "==1" },
{ name = "openhands-tools", specifier = "==1" },
{ name = "openhands-sdk", specifier = "==1.1" },
{ name = "openhands-tools", specifier = "==1.1" },
{ name = "prompt-toolkit", specifier = ">=3" },
{ name = "typer", specifier = ">=0.17.4" },
]
@@ -1953,7 +1953,7 @@ dev = [
[[package]]
name = "openhands-sdk"
version = "1.0.0"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastmcp" },
@@ -1966,14 +1966,14 @@ dependencies = [
{ name = "tenacity" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/58/d6117840a14d013176a7a490a74295dffac64b44dc098532d4e8526c9a87/openhands_sdk-1.0.0.tar.gz", hash = "sha256:7c3a0d77d48d7eceaa77fda90ac654697ce916431b5c905d10d9ab6c07609a1a", size = 160726, upload-time = "2025-11-06T17:05:44.545Z" }
sdist = { url = "https://files.pythonhosted.org/packages/90/b2/97d9deb743b266683f3e70cebaa1d34ee247c019f7d6e42c2f5de529cb47/openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c", size = 166636, upload-time = "2025-11-11T19:07:04.249Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/9b/4d4c356ed50e6ad87e6dc8f87af1966c51c55a22955cebd632bf62040e5b/openhands_sdk-1.0.0-py3-none-any.whl", hash = "sha256:73916e22783e2c8500f19765fa340631a0e47ae9a3c5e40fb8411ecab4a1f49a", size = 214807, upload-time = "2025-11-06T17:05:43.474Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9f/a97a10447f3be53df4639e43748c4178853e958df07ba74890f4968829d6/openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e", size = 221594, upload-time = "2025-11-11T19:07:02.847Z" },
]
[[package]]
name = "openhands-tools"
version = "1.0.0"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bashlex" },
@@ -1985,9 +1985,9 @@ dependencies = [
{ name = "openhands-sdk" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/45/49/3bad4d8283c76f72dacfde8fece9d1190774c87c40a011075868e8d18cbf/openhands_tools-1.0.0.tar.gz", hash = "sha256:f6bc8647149d541730520f1aeb409cd9eac96d796d19e39a40f300dcd2b0284c", size = 61997, upload-time = "2025-11-06T17:05:46.455Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/89/e2c5fc2d9e8dc6840ef2891ff6f76b9769b50a4c508fd3a626c1ab476fb1/openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6", size = 62818, upload-time = "2025-11-11T19:07:06.527Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/15/23c5650a9470f9c125288508bf966e6b2ece479f5407801aa7fdda2ba5a0/openhands_tools-1.0.0-py3-none-any.whl", hash = "sha256:21a4ff3f37a3c71edd17b861fe1a9b86cc744ad9dc8a3626898ecdeeea7ae30f", size = 84232, upload-time = "2025-11-06T17:05:45.527Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a3/e58d75b7bd8d5dfbe063fcfaaadbdfd24fd511d633a528cefd29f0e01056/openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9", size = 85062, upload-time = "2025-11-11T19:07:05.315Z" },
]
[[package]]

View File

@@ -215,11 +215,12 @@ class LiveStatusAppConversationService(GitAppConversationService):
yield task
# Start conversation...
body_json = start_conversation_request.model_dump(
mode='json', context={'expose_secrets': True}
)
response = await self.httpx_client.post(
f'{agent_server_url}/api/conversations',
json=start_conversation_request.model_dump(
mode='json', context={'expose_secrets': True}
),
json=body_json,
headers={'X-Session-API-Key': sandbox.session_api_key},
timeout=self.sandbox_startup_timeout,
)

View File

@@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:d5995c3-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:f3c0c19-python'
class SandboxSpecService(ABC):

View File

@@ -42,6 +42,10 @@ from openhands.core.exceptions import (
from openhands.core.logger import LOG_ALL_EVENTS
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema import AgentState
from openhands.utils.posthog_tracker import (
track_agent_task_completed,
track_credit_limit_reached,
)
from openhands.events import (
EventSource,
EventStream,
@@ -709,6 +713,20 @@ class AgentController:
EventSource.ENVIRONMENT,
)
# Track agent task completion in PostHog
if new_state == AgentState.FINISHED:
try:
# Get app_mode from environment, default to 'oss'
app_mode = os.environ.get('APP_MODE', 'oss')
track_agent_task_completed(
conversation_id=self.id,
user_id=self.user_id,
app_mode=app_mode,
)
except Exception as e:
# Don't let tracking errors interrupt the agent
self.log('warning', f'Failed to track agent completion: {e}')
# Save state whenever agent state changes to ensure we don't lose state
# in case of crashes or unexpected circumstances
self.save_state()
@@ -887,6 +905,18 @@ class AgentController:
self.state_tracker.run_control_flags()
except Exception as e:
logger.warning('Control flag limits hit')
# Track credit limit reached if it's a budget exception
if 'budget' in str(e).lower() and self.state.budget_flag:
try:
track_credit_limit_reached(
conversation_id=self.id,
user_id=self.user_id,
current_budget=self.state.budget_flag.current_value,
max_budget=self.state.budget_flag.max_value,
)
except Exception as track_error:
# Don't let tracking errors interrupt the agent
self.log('warning', f'Failed to track credit limit: {track_error}')
await self._react_to_exception(e)
return

View File

@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.61-nikolaik"
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.62-nikolaik"
```
#### Additional Kubernetes Options

View File

@@ -26,11 +26,13 @@ from openhands.microagent.types import (
)
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import server_config
from openhands.server.types import AppMode
from openhands.server.user_auth import (
get_access_token,
get_provider_tokens,
get_user_id,
)
from openhands.utils.posthog_tracker import alias_user_identities
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
@@ -115,6 +117,14 @@ async def get_user(
try:
user: User = await client.get_user()
# Alias git provider login with Keycloak user ID in PostHog (SaaS mode only)
if user_id and user.login and server_config.app_mode == AppMode.SAAS:
alias_user_identities(
keycloak_user_id=user_id,
git_login=user.login,
)
return user
except UnknownException as e:

View File

@@ -0,0 +1,270 @@
"""PostHog tracking utilities for OpenHands events."""
import os
from openhands.core.logger import openhands_logger as logger
# Lazy import posthog to avoid import errors in environments where it's not installed
posthog = None
def _init_posthog():
"""Initialize PostHog client lazily."""
global posthog
if posthog is None:
try:
import posthog as ph
posthog = ph
posthog.api_key = os.environ.get(
'POSTHOG_CLIENT_KEY', 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA'
)
posthog.host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
except ImportError:
logger.warning(
'PostHog not installed. Analytics tracking will be disabled.'
)
posthog = None
def track_agent_task_completed(
conversation_id: str,
user_id: str | None = None,
app_mode: str | None = None,
) -> None:
"""Track when an agent completes a task.
Args:
conversation_id: The ID of the conversation/session
user_id: The ID of the user (optional, may be None for unauthenticated users)
app_mode: The application mode (saas/oss), optional
"""
_init_posthog()
if posthog is None:
return
# Use conversation_id as distinct_id if user_id is not available
# This ensures we can track completions even for anonymous users
distinct_id = user_id if user_id else f'conversation_{conversation_id}'
try:
posthog.capture(
distinct_id=distinct_id,
event='agent_task_completed',
properties={
'conversation_id': conversation_id,
'user_id': user_id,
'app_mode': app_mode or 'unknown',
},
)
logger.debug(
'posthog_track',
extra={
'event': 'agent_task_completed',
'conversation_id': conversation_id,
'user_id': user_id,
},
)
except Exception as e:
logger.warning(
f'Failed to track agent_task_completed to PostHog: {e}',
extra={
'conversation_id': conversation_id,
'error': str(e),
},
)
def track_user_signup_completed(
user_id: str,
signup_timestamp: str,
) -> None:
"""Track when a user completes signup by accepting TOS.
Args:
user_id: The ID of the user (Keycloak user ID)
signup_timestamp: ISO format timestamp of when TOS was accepted
"""
_init_posthog()
if posthog is None:
return
try:
posthog.capture(
distinct_id=user_id,
event='user_signup_completed',
properties={
'user_id': user_id,
'signup_timestamp': signup_timestamp,
},
)
logger.debug(
'posthog_track',
extra={
'event': 'user_signup_completed',
'user_id': user_id,
},
)
except Exception as e:
logger.warning(
f'Failed to track user_signup_completed to PostHog: {e}',
extra={
'user_id': user_id,
'error': str(e),
},
)
def track_credit_limit_reached(
conversation_id: str,
user_id: str | None = None,
current_budget: float = 0.0,
max_budget: float = 0.0,
) -> None:
"""Track when a user reaches their credit limit during a conversation.
Args:
conversation_id: The ID of the conversation/session
user_id: The ID of the user (optional, may be None for unauthenticated users)
current_budget: The current budget spent
max_budget: The maximum budget allowed
"""
_init_posthog()
if posthog is None:
return
distinct_id = user_id if user_id else f'conversation_{conversation_id}'
try:
posthog.capture(
distinct_id=distinct_id,
event='credit_limit_reached',
properties={
'conversation_id': conversation_id,
'user_id': user_id,
'current_budget': current_budget,
'max_budget': max_budget,
},
)
logger.debug(
'posthog_track',
extra={
'event': 'credit_limit_reached',
'conversation_id': conversation_id,
'user_id': user_id,
'current_budget': current_budget,
'max_budget': max_budget,
},
)
except Exception as e:
logger.warning(
f'Failed to track credit_limit_reached to PostHog: {e}',
extra={
'conversation_id': conversation_id,
'error': str(e),
},
)
def track_credits_purchased(
user_id: str,
amount_usd: float,
credits_added: float,
stripe_session_id: str,
) -> None:
"""Track when a user successfully purchases credits.
Args:
user_id: The ID of the user (Keycloak user ID)
amount_usd: The amount paid in USD (cents converted to dollars)
credits_added: The number of credits added to the user's account
stripe_session_id: The Stripe checkout session ID
"""
_init_posthog()
if posthog is None:
return
try:
posthog.capture(
distinct_id=user_id,
event='credits_purchased',
properties={
'user_id': user_id,
'amount_usd': amount_usd,
'credits_added': credits_added,
'stripe_session_id': stripe_session_id,
},
)
logger.debug(
'posthog_track',
extra={
'event': 'credits_purchased',
'user_id': user_id,
'amount_usd': amount_usd,
'credits_added': credits_added,
},
)
except Exception as e:
logger.warning(
f'Failed to track credits_purchased to PostHog: {e}',
extra={
'user_id': user_id,
'error': str(e),
},
)
def alias_user_identities(
keycloak_user_id: str,
git_login: str,
) -> None:
"""Alias a user's Keycloak ID with their git provider login for unified tracking.
This allows PostHog to link events tracked from the frontend (using git provider login)
with events tracked from the backend (using Keycloak user ID).
PostHog Python alias syntax: alias(previous_id, distinct_id)
- previous_id: The old/previous distinct ID that will be merged
- distinct_id: The new/canonical distinct ID to merge into
For our use case:
- Git provider login is the previous_id (first used in frontend, before backend auth)
- Keycloak user ID is the distinct_id (canonical backend ID)
- Result: All events with git login will be merged into Keycloak user ID
Args:
keycloak_user_id: The Keycloak user ID (canonical distinct_id)
git_login: The git provider username (GitHub/GitLab/Bitbucket) to merge
Reference:
https://github.com/PostHog/posthog-python/blob/master/posthog/client.py
"""
_init_posthog()
if posthog is None:
return
try:
# Merge git provider login into Keycloak user ID
# posthog.alias(previous_id, distinct_id) - official Python SDK signature
posthog.alias(git_login, keycloak_user_id)
logger.debug(
'posthog_alias',
extra={
'previous_id': git_login,
'distinct_id': keycloak_user_id,
},
)
except Exception as e:
logger.warning(
f'Failed to alias user identities in PostHog: {e}',
extra={
'keycloak_user_id': keycloak_user_id,
'git_login': git_login,
'error': str(e),
},
)

20
poetry.lock generated
View File

@@ -7329,14 +7329,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.0.0a6"
version = "1.1.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.0.0a6-py3-none-any.whl", hash = "sha256:72b0da038ede018c55c64f0ac99bc5d991af173627efc63de87d54b3cd69134c"},
{file = "openhands_agent_server-1.0.0a6.tar.gz", hash = "sha256:8c6fbceb07990e3caf7f8797082d1bb614b9f7339bd00576c24fd34a956a03b4"},
{file = "openhands_agent_server-1.1.0-py3-none-any.whl", hash = "sha256:59a856883df23488c0723e47655ef21649a321fcd4709a25a4690866eff6ac88"},
{file = "openhands_agent_server-1.1.0.tar.gz", hash = "sha256:e39bebd39afd45cfcfd765005e7c4e5409e46678bd7612ae20bae79f7057b935"},
]
[package.dependencies]
@@ -7352,14 +7352,14 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-sdk"
version = "1.0.0a6"
version = "1.1.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.0.0a6-py3-none-any.whl", hash = "sha256:0b0b579fc48a5b7eaa418ca66188206ba00f4d883997bc29291bc1745e0b7ddc"},
{file = "openhands_sdk-1.0.0a6.tar.gz", hash = "sha256:01daff435c5f94037b9b4ba85054097ca6235982a9b0fee00341279d4c4b5a01"},
{file = "openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e"},
{file = "openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c"},
]
[package.dependencies]
@@ -7378,14 +7378,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.0.0a6"
version = "1.1.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.0.0a6-py3-none-any.whl", hash = "sha256:55b75016f7e3930e4365393a026726eeffae027363d03862a17a8cebc1aed670"},
{file = "openhands_tools-1.0.0a6.tar.gz", hash = "sha256:4d5382f3e1cab9d23c1ef7ea8e36e821083886d6d4b019100cbf897e3b0cd3be"},
{file = "openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9"},
{file = "openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6"},
]
[package.dependencies]
@@ -16729,4 +16729,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "57ed6b7f4613e668fd1d0e10a21f7c915cdbb9c7b906a0b71a8ba222733c082d"
content-hash = "0fe5bab6aeb5ebce4588b30cfcf491af4cc9d9b9cd5160e67c8a055d9db276fc"

View File

@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.61.0"
version = "0.62.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -113,12 +113,12 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
openhands-sdk = "1.0.0a6"
openhands-agent-server = "1.0.0a6"
openhands-tools = "1.0.0a6"
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "f3c0c19cd134fbda84e07f152897a6d61e1e46c5" }
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "f3c0c19cd134fbda84e07f152897a6d61e1e46c5" }
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "f3c0c19cd134fbda84e07f152897a6d61e1e46c5" }
openhands-sdk = "1.1.0"
openhands-agent-server = "1.1.0"
openhands-tools = "1.1.0"
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"

View File

@@ -0,0 +1,242 @@
"""Integration tests for PostHog tracking in AgentController."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from openhands.controller.agent import Agent
from openhands.controller.agent_controller import AgentController
from openhands.core.config import OpenHandsConfig
from openhands.core.config.agent_config import AgentConfig
from openhands.core.config.llm_config import LLMConfig
from openhands.core.schema import AgentState
from openhands.events import EventSource, EventStream
from openhands.events.action.message import SystemMessageAction
from openhands.llm.llm_registry import LLMRegistry
from openhands.server.services.conversation_stats import ConversationStats
from openhands.storage.memory import InMemoryFileStore
@pytest.fixture(scope='function')
def event_loop():
"""Create event loop for async tests."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture
def mock_agent_with_stats():
"""Create a mock agent with properly connected LLM registry and conversation stats."""
import uuid
# Create LLM registry
config = OpenHandsConfig()
llm_registry = LLMRegistry(config=config)
# Create conversation stats
file_store = InMemoryFileStore({})
conversation_id = f'test-conversation-{uuid.uuid4()}'
conversation_stats = ConversationStats(
file_store=file_store, conversation_id=conversation_id, user_id='test-user'
)
# Connect registry to stats
llm_registry.subscribe(conversation_stats.register_llm)
# Create mock agent
agent = MagicMock(spec=Agent)
agent_config = MagicMock(spec=AgentConfig)
llm_config = LLMConfig(
model='gpt-4o',
api_key='test_key',
num_retries=2,
retry_min_wait=1,
retry_max_wait=2,
)
agent_config.disabled_microagents = []
agent_config.enable_mcp = True
llm_registry.service_to_llm.clear()
mock_llm = llm_registry.get_llm('agent_llm', llm_config)
agent.llm = mock_llm
agent.name = 'test-agent'
agent.sandbox_plugins = []
agent.config = agent_config
agent.llm_registry = llm_registry
agent.prompt_manager = MagicMock()
# Add a proper system message mock
system_message = SystemMessageAction(
content='Test system message', tools=['test_tool']
)
system_message._source = EventSource.AGENT
system_message._id = -1 # Set invalid ID to avoid the ID check
agent.get_system_message.return_value = system_message
return agent, conversation_stats, llm_registry
@pytest.fixture
def mock_event_stream():
"""Create a mock event stream."""
mock = MagicMock(
spec=EventStream,
event_stream=EventStream(sid='test', file_store=InMemoryFileStore({})),
)
mock.get_latest_event_id.return_value = 0
return mock
@pytest.mark.asyncio
async def test_agent_finish_triggers_posthog_tracking(
mock_agent_with_stats, mock_event_stream
):
"""Test that setting agent state to FINISHED triggers PostHog tracking."""
mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
conversation_stats=conversation_stats,
iteration_delta=10,
sid='test-conversation-123',
user_id='test-user-456',
confirmation_mode=False,
headless_mode=True,
)
with (
patch('openhands.utils.posthog_tracker.posthog') as mock_posthog,
patch('os.environ.get') as mock_env_get,
):
# Setup mocks
mock_posthog.capture = MagicMock()
mock_env_get.return_value = 'saas'
# Initialize posthog in the tracker module
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
# Set agent state to FINISHED
await controller.set_agent_state_to(AgentState.FINISHED)
# Verify PostHog tracking was called
mock_posthog.capture.assert_called_once()
call_args = mock_posthog.capture.call_args
assert call_args[1]['distinct_id'] == 'test-user-456'
assert call_args[1]['event'] == 'agent_task_completed'
assert 'conversation_id' in call_args[1]['properties']
assert call_args[1]['properties']['user_id'] == 'test-user-456'
assert call_args[1]['properties']['app_mode'] == 'saas'
await controller.close()
@pytest.mark.asyncio
async def test_agent_finish_without_user_id(mock_agent_with_stats, mock_event_stream):
"""Test tracking when user_id is None."""
mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
conversation_stats=conversation_stats,
iteration_delta=10,
sid='test-conversation-789',
user_id=None,
confirmation_mode=False,
headless_mode=True,
)
with (
patch('openhands.utils.posthog_tracker.posthog') as mock_posthog,
patch('os.environ.get') as mock_env_get,
):
mock_posthog.capture = MagicMock()
mock_env_get.return_value = 'oss'
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
await controller.set_agent_state_to(AgentState.FINISHED)
mock_posthog.capture.assert_called_once()
call_args = mock_posthog.capture.call_args
# When user_id is None, distinct_id should be conversation_id
assert call_args[1]['distinct_id'].startswith('conversation_')
assert call_args[1]['properties']['user_id'] is None
await controller.close()
@pytest.mark.asyncio
async def test_other_states_dont_trigger_tracking(
mock_agent_with_stats, mock_event_stream
):
"""Test that non-FINISHED states don't trigger tracking."""
mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
conversation_stats=conversation_stats,
iteration_delta=10,
sid='test-conversation-999',
confirmation_mode=False,
headless_mode=True,
)
with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog:
mock_posthog.capture = MagicMock()
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
# Try different states
await controller.set_agent_state_to(AgentState.RUNNING)
await controller.set_agent_state_to(AgentState.PAUSED)
await controller.set_agent_state_to(AgentState.STOPPED)
# PostHog should not be called for non-FINISHED states
mock_posthog.capture.assert_not_called()
await controller.close()
@pytest.mark.asyncio
async def test_tracking_error_doesnt_break_agent(
mock_agent_with_stats, mock_event_stream
):
"""Test that tracking errors don't interrupt agent operation."""
mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
conversation_stats=conversation_stats,
iteration_delta=10,
sid='test-conversation-error',
confirmation_mode=False,
headless_mode=True,
)
with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog:
mock_posthog.capture = MagicMock(side_effect=Exception('PostHog error'))
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
# Should not raise an exception
await controller.set_agent_state_to(AgentState.FINISHED)
# Agent state should still be FINISHED despite tracking error
assert controller.state.agent_state == AgentState.FINISHED
await controller.close()

View File

@@ -0,0 +1,356 @@
"""Unit tests for PostHog tracking utilities."""
from unittest.mock import MagicMock, patch
import pytest
from openhands.utils.posthog_tracker import (
alias_user_identities,
track_agent_task_completed,
track_credit_limit_reached,
track_credits_purchased,
track_user_signup_completed,
)
@pytest.fixture
def mock_posthog():
"""Mock the posthog module."""
with patch('openhands.utils.posthog_tracker.posthog') as mock_ph:
mock_ph.capture = MagicMock()
yield mock_ph
def test_track_agent_task_completed_with_user_id(mock_posthog):
"""Test tracking agent task completion with user ID."""
# Initialize posthog manually in the test
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
track_agent_task_completed(
conversation_id='test-conversation-123',
user_id='user-456',
app_mode='saas',
)
mock_posthog.capture.assert_called_once_with(
distinct_id='user-456',
event='agent_task_completed',
properties={
'conversation_id': 'test-conversation-123',
'user_id': 'user-456',
'app_mode': 'saas',
},
)
def test_track_agent_task_completed_without_user_id(mock_posthog):
"""Test tracking agent task completion without user ID (anonymous)."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
track_agent_task_completed(
conversation_id='test-conversation-789',
user_id=None,
app_mode='oss',
)
mock_posthog.capture.assert_called_once_with(
distinct_id='conversation_test-conversation-789',
event='agent_task_completed',
properties={
'conversation_id': 'test-conversation-789',
'user_id': None,
'app_mode': 'oss',
},
)
def test_track_agent_task_completed_default_app_mode(mock_posthog):
"""Test tracking with default app_mode."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
track_agent_task_completed(
conversation_id='test-conversation-999',
user_id='user-111',
)
mock_posthog.capture.assert_called_once_with(
distinct_id='user-111',
event='agent_task_completed',
properties={
'conversation_id': 'test-conversation-999',
'user_id': 'user-111',
'app_mode': 'unknown',
},
)
def test_track_agent_task_completed_handles_errors(mock_posthog):
"""Test that tracking errors are handled gracefully."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
mock_posthog.capture.side_effect = Exception('PostHog API error')
# Should not raise an exception
track_agent_task_completed(
conversation_id='test-conversation-error',
user_id='user-error',
app_mode='saas',
)
def test_track_agent_task_completed_when_posthog_not_installed():
"""Test tracking when posthog is not installed."""
import openhands.utils.posthog_tracker as tracker
# Simulate posthog not being installed
tracker.posthog = None
# Should not raise an exception
track_agent_task_completed(
conversation_id='test-conversation-no-ph',
user_id='user-no-ph',
app_mode='oss',
)
def test_track_user_signup_completed(mock_posthog):
"""Test tracking user signup completion."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
track_user_signup_completed(
user_id='test-user-123',
signup_timestamp='2025-01-15T10:30:00Z',
)
mock_posthog.capture.assert_called_once_with(
distinct_id='test-user-123',
event='user_signup_completed',
properties={
'user_id': 'test-user-123',
'signup_timestamp': '2025-01-15T10:30:00Z',
},
)
def test_track_user_signup_completed_handles_errors(mock_posthog):
"""Test that user signup tracking errors are handled gracefully."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
mock_posthog.capture.side_effect = Exception('PostHog API error')
# Should not raise an exception
track_user_signup_completed(
user_id='test-user-error',
signup_timestamp='2025-01-15T12:00:00Z',
)
def test_track_user_signup_completed_when_posthog_not_installed():
"""Test user signup tracking when posthog is not installed."""
import openhands.utils.posthog_tracker as tracker
# Simulate posthog not being installed
tracker.posthog = None
# Should not raise an exception
track_user_signup_completed(
user_id='test-user-no-ph',
signup_timestamp='2025-01-15T13:00:00Z',
)
def test_track_credit_limit_reached_with_user_id(mock_posthog):
"""Test tracking credit limit reached with user ID."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
track_credit_limit_reached(
conversation_id='test-conversation-456',
user_id='user-789',
current_budget=10.50,
max_budget=10.00,
)
mock_posthog.capture.assert_called_once_with(
distinct_id='user-789',
event='credit_limit_reached',
properties={
'conversation_id': 'test-conversation-456',
'user_id': 'user-789',
'current_budget': 10.50,
'max_budget': 10.00,
},
)
def test_track_credit_limit_reached_without_user_id(mock_posthog):
"""Test tracking credit limit reached without user ID (anonymous)."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
track_credit_limit_reached(
conversation_id='test-conversation-999',
user_id=None,
current_budget=5.25,
max_budget=5.00,
)
mock_posthog.capture.assert_called_once_with(
distinct_id='conversation_test-conversation-999',
event='credit_limit_reached',
properties={
'conversation_id': 'test-conversation-999',
'user_id': None,
'current_budget': 5.25,
'max_budget': 5.00,
},
)
def test_track_credit_limit_reached_handles_errors(mock_posthog):
"""Test that credit limit tracking errors are handled gracefully."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
mock_posthog.capture.side_effect = Exception('PostHog API error')
# Should not raise an exception
track_credit_limit_reached(
conversation_id='test-conversation-error',
user_id='user-error',
current_budget=15.00,
max_budget=10.00,
)
def test_track_credit_limit_reached_when_posthog_not_installed():
"""Test credit limit tracking when posthog is not installed."""
import openhands.utils.posthog_tracker as tracker
# Simulate posthog not being installed
tracker.posthog = None
# Should not raise an exception
track_credit_limit_reached(
conversation_id='test-conversation-no-ph',
user_id='user-no-ph',
current_budget=8.00,
max_budget=5.00,
)
def test_track_credits_purchased(mock_posthog):
"""Test tracking credits purchased."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
track_credits_purchased(
user_id='test-user-999',
amount_usd=50.00,
credits_added=50.00,
stripe_session_id='cs_test_abc123',
)
mock_posthog.capture.assert_called_once_with(
distinct_id='test-user-999',
event='credits_purchased',
properties={
'user_id': 'test-user-999',
'amount_usd': 50.00,
'credits_added': 50.00,
'stripe_session_id': 'cs_test_abc123',
},
)
def test_track_credits_purchased_handles_errors(mock_posthog):
"""Test that credits purchased tracking errors are handled gracefully."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
mock_posthog.capture.side_effect = Exception('PostHog API error')
# Should not raise an exception
track_credits_purchased(
user_id='test-user-error',
amount_usd=100.00,
credits_added=100.00,
stripe_session_id='cs_test_error',
)
def test_track_credits_purchased_when_posthog_not_installed():
"""Test credits purchased tracking when posthog is not installed."""
import openhands.utils.posthog_tracker as tracker
# Simulate posthog not being installed
tracker.posthog = None
# Should not raise an exception
track_credits_purchased(
user_id='test-user-no-ph',
amount_usd=25.00,
credits_added=25.00,
stripe_session_id='cs_test_no_ph',
)
def test_alias_user_identities(mock_posthog):
"""Test aliasing user identities.
Verifies that posthog.alias(previous_id, distinct_id) is called correctly
where git_login is the previous_id and keycloak_user_id is the distinct_id.
"""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
mock_posthog.alias = MagicMock()
alias_user_identities(
keycloak_user_id='keycloak-123',
git_login='git-user',
)
# Verify: posthog.alias(previous_id='git-user', distinct_id='keycloak-123')
mock_posthog.alias.assert_called_once_with('git-user', 'keycloak-123')
def test_alias_user_identities_handles_errors(mock_posthog):
"""Test that aliasing errors are handled gracefully."""
import openhands.utils.posthog_tracker as tracker
tracker.posthog = mock_posthog
mock_posthog.alias = MagicMock(side_effect=Exception('PostHog API error'))
# Should not raise an exception
alias_user_identities(
keycloak_user_id='keycloak-error',
git_login='git-error',
)
def test_alias_user_identities_when_posthog_not_installed():
"""Test aliasing when posthog is not installed."""
import openhands.utils.posthog_tracker as tracker
# Simulate posthog not being installed
tracker.posthog = None
# Should not raise an exception
alias_user_identities(
keycloak_user_id='keycloak-no-ph',
git_login='git-no-ph',
)