Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions migrations/versions/016_add_run_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Add run_metadata column to automation_runs.

Stores additional execution metadata captured after a run completes, such as
structured semantic task outcomes parsed from preset conversation finish actions.

Revision ID: 016
Revises: 015
Create Date: 2026-08-12
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op


revision: str = "016"
down_revision: str = "015"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column("automation_runs", sa.Column("run_metadata", sa.JSON, nullable=True))


def downgrade() -> None:
op.drop_column("automation_runs", "run_metadata")
15 changes: 13 additions & 2 deletions openhands/automation/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@
from openhands.automation.scheduler import scheduler_loop
from openhands.automation.telemetry_router import router as telemetry_router
from openhands.automation.uploads import router as uploads_router
from openhands.automation.utils.version import get_sdk_version, get_server_version_info
from openhands.automation.utils.version import (
get_sdk_install_spec,
get_sdk_version,
get_server_version_info,
get_tools_install_spec,
)
from openhands.automation.watchdog import watchdog_loop
from openhands.automation.webhook_router import router as webhook_router

Expand Down Expand Up @@ -327,12 +332,18 @@ async def sdk_version():
"""
try:
version = get_sdk_version()
install_spec = get_sdk_install_spec()
tools_install_spec = get_tools_install_spec()
except PackageNotFoundError:
return JSONResponse(
status_code=503,
content={"error": "openhands-sdk package not found"},
)
return {"version": version}
return {
"version": version,
"install_spec": install_spec,
"tools_install_spec": tools_install_spec,
}


@app.get("/server_info")
Expand Down
5 changes: 5 additions & 0 deletions openhands/automation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ class AutomationRun(Base):
# Uses generic JSON type for cross-database compatibility (PostgreSQL + SQLite)
event_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)

# Additional metadata captured during run execution.
# For preset automations this may include the semantic task outcome parsed
# from the final conversation action.
run_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)

# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
Expand Down
7 changes: 6 additions & 1 deletion openhands/automation/presets/plugin/sdk_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@

# SDK imports (before workspace context so import errors are caught)
from openhands.sdk import Conversation, RemoteConversation
from openhands.tools.preset import TaskOutcome

try:
from openhands.sdk.mcp.config import coerce_mcp_config as _coerce_mcp_config
Expand Down Expand Up @@ -395,7 +396,11 @@ def _build_conversation_title(event_context) -> str | None:

# Get default agent with tools and condenser (CLI mode to disable browser)
print("\n=== AGENT ===")
agent = get_default_agent(llm=llm, cli_mode=True)
agent = get_default_agent(
llm=llm,
cli_mode=True,
finish_tool_response_schema=TaskOutcome,
Comment thread
malhotra5 marked this conversation as resolved.
)

# Add MCP config and agent_context using model_copy if configured
# (Plugin MCP configs will be merged when plugins are loaded)
Expand Down
17 changes: 11 additions & 6 deletions openhands/automation/presets/plugin/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,16 @@ if ! command -v python3 >/dev/null 2>&1; then
fi
fi
set +e
SDK_VERSION=$(curl -sf "${AUTOMATION_API_URL}/sdk-version" \
SDK_METADATA=$(curl -sf "${AUTOMATION_API_URL}/sdk-version")
SDK_VERSION=$(printf '%s' "$SDK_METADATA" \
| ${PYTHON_JSON} -c "import sys, json; print(json.load(sys.stdin)['version'])" 2>/dev/null)
SDK_INSTALL_SPEC=$(printf '%s' "$SDK_METADATA" \
| ${PYTHON_JSON} -c "import sys, json; data=json.load(sys.stdin); print(data.get('install_spec') or ('openhands-sdk==' + data['version']))" 2>/dev/null)
TOOLS_INSTALL_SPEC=$(printf '%s' "$SDK_METADATA" \
| ${PYTHON_JSON} -c "import sys, json; data=json.load(sys.stdin); print(data.get('tools_install_spec') or ('openhands-tools==' + data['version']))" 2>/dev/null)
set -e
if [ -z "$SDK_VERSION" ]; then
echo "[setup] ERROR: Failed to fetch SDK version from ${AUTOMATION_API_URL}/sdk-version" >&2
if [ -z "$SDK_VERSION" ] || [ -z "$SDK_INSTALL_SPEC" ] || [ -z "$TOOLS_INSTALL_SPEC" ]; then
echo "[setup] ERROR: Failed to fetch SDK install metadata from ${AUTOMATION_API_URL}/sdk-version" >&2
exit 1
fi

Expand All @@ -39,10 +44,10 @@ echo "[setup] Creating isolated virtual environment"
# CommandLineTools 3.9), which can't satisfy openhands-sdk's requires-python.
uv venv .venv --python '>=3.12' --quiet

echo "[setup] Installing OpenHands SDK from PyPI (version: $SDK_VERSION)"
echo "[setup] Installing OpenHands SDK ($SDK_INSTALL_SPEC)"
uv pip install --quiet \
"openhands-sdk==${SDK_VERSION}" \
"openhands-tools==${SDK_VERSION}" \
"$SDK_INSTALL_SPEC" \
"$TOOLS_INSTALL_SPEC" \
"openhands-workspace==${SDK_VERSION}"

echo "[setup] Done"
7 changes: 6 additions & 1 deletion openhands/automation/presets/prompt/sdk_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@

# SDK imports (before workspace context so import errors are caught)
from openhands.sdk import Conversation, RemoteConversation
from openhands.tools.preset import TaskOutcome

try:
from openhands.sdk.mcp.config import coerce_mcp_config as _coerce_mcp_config
Expand Down Expand Up @@ -364,7 +365,11 @@ def _build_conversation_title(event_context) -> str | None:

# Get default agent with tools and condenser (CLI mode to disable browser)
print("\n=== AGENT ===")
agent = get_default_agent(llm=llm, cli_mode=True)
agent = get_default_agent(
llm=llm,
cli_mode=True,
finish_tool_response_schema=TaskOutcome,
)

# Add MCP config and agent_context using model_copy if configured
agent_updates = {}
Expand Down
17 changes: 11 additions & 6 deletions openhands/automation/presets/prompt/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,16 @@ if ! command -v python3 >/dev/null 2>&1; then
fi
fi
set +e
SDK_VERSION=$(curl -sf "${AUTOMATION_API_URL}/sdk-version" \
SDK_METADATA=$(curl -sf "${AUTOMATION_API_URL}/sdk-version")
SDK_VERSION=$(printf '%s' "$SDK_METADATA" \
| ${PYTHON_JSON} -c "import sys, json; print(json.load(sys.stdin)['version'])" 2>/dev/null)
SDK_INSTALL_SPEC=$(printf '%s' "$SDK_METADATA" \
| ${PYTHON_JSON} -c "import sys, json; data=json.load(sys.stdin); print(data.get('install_spec') or ('openhands-sdk==' + data['version']))" 2>/dev/null)
TOOLS_INSTALL_SPEC=$(printf '%s' "$SDK_METADATA" \
| ${PYTHON_JSON} -c "import sys, json; data=json.load(sys.stdin); print(data.get('tools_install_spec') or ('openhands-tools==' + data['version']))" 2>/dev/null)
set -e
if [ -z "$SDK_VERSION" ]; then
echo "[setup] ERROR: Failed to fetch SDK version from ${AUTOMATION_API_URL}/sdk-version" >&2
if [ -z "$SDK_VERSION" ] || [ -z "$SDK_INSTALL_SPEC" ] || [ -z "$TOOLS_INSTALL_SPEC" ]; then
echo "[setup] ERROR: Failed to fetch SDK install metadata from ${AUTOMATION_API_URL}/sdk-version" >&2
exit 1
fi

Expand All @@ -39,10 +44,10 @@ echo "[setup] Creating isolated virtual environment"
# CommandLineTools 3.9), which can't satisfy openhands-sdk's requires-python.
uv venv .venv --python '>=3.12' --quiet

echo "[setup] Installing OpenHands SDK from PyPI (version: $SDK_VERSION)"
echo "[setup] Installing OpenHands SDK ($SDK_INSTALL_SPEC)"
uv pip install --quiet \
"openhands-sdk==${SDK_VERSION}" \
"openhands-tools==${SDK_VERSION}" \
"$SDK_INSTALL_SPEC" \
"$TOOLS_INSTALL_SPEC" \
"openhands-workspace==${SDK_VERSION}"

echo "[setup] Done"
12 changes: 12 additions & 0 deletions openhands/automation/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
APIKeyError,
get_api_key_for_automation_run,
)
from openhands.automation.utils.conversation_outcome import (
fetch_latest_finish_tool_response_for_run,
)
from openhands.automation.utils.model_profiles import resolve_model_profile_for_user
from openhands.automation.utils.run import create_pending_run, record_first_run_outcome
from openhands.automation.utils.sandbox import cleanup_sandbox
Expand Down Expand Up @@ -461,6 +464,15 @@ async def complete_run(
values["cost"] = body.cost
if body.status == "FAILED" and body.error:
values["error_detail"] = body.error
if body.conversation_id:
finish_tool_response = await fetch_latest_finish_tool_response_for_run(
run, body.conversation_id
)
if finish_tool_response is not None:
values["run_metadata"] = {
**(run.run_metadata or {}),
"finish_tool_response": finish_tool_response,
}

stmt = (
update(AutomationRun)
Expand Down
1 change: 1 addition & 0 deletions openhands/automation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@ class AutomationRunResponse(BaseModel):
timeout_at: UtcDatetime | None
sandbox_id: str | None
bash_command_id: str | None = None
run_metadata: dict[str, Any] | None = None
created_at: UtcDatetime
started_at: UtcDatetime | None
completed_at: UtcDatetime | None
Expand Down
121 changes: 121 additions & 0 deletions openhands/automation/utils/conversation_outcome.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Helpers for reading raw FinishTool responses from conversations."""

from __future__ import annotations

import json
import logging
from typing import Any

import httpx

from openhands.automation.backends import get_backend
from openhands.automation.config import get_config
from openhands.automation.models import AutomationRun
from openhands.automation.utils.sandbox import get_sandbox_agent_url


logger = logging.getLogger(__name__)

ACTION_EVENT_KIND = "openhands.sdk.event.llm_convertible.action.ActionEvent"
FINISH_TOOL_NAME = "finish"


def finish_tool_response_from_event(event: dict[str, Any]) -> Any | None:
"""Return the raw JSON-decoded arguments from a FinishTool action event."""
if event.get("tool_name") != FINISH_TOOL_NAME:
return None

tool_call = event.get("tool_call")
if not isinstance(tool_call, dict):
return None

raw_arguments = tool_call.get("arguments")
if not isinstance(raw_arguments, str):
return None

try:
return json.loads(raw_arguments)
except json.JSONDecodeError:
logger.warning("Could not decode finish tool arguments as JSON")
return raw_arguments


def latest_finish_tool_response_from_events(
events: list[dict[str, Any]],
) -> Any | None:
"""Return the latest finish action response from newest-first events."""
for event in events:
if event.get("tool_name") == FINISH_TOOL_NAME:
return finish_tool_response_from_event(event)
return None


async def fetch_latest_finish_tool_response(
client: httpx.AsyncClient,
agent_url: str,
session_key: str,
conversation_id: str,
) -> Any | None:
"""Fetch recent conversation actions and return the latest finish response."""
response = await client.get(
f"{agent_url.rstrip('/')}/api/conversations/{conversation_id}/events/search",
params={
"kind": ACTION_EVENT_KIND,
"sort_order": "TIMESTAMP_DESC",
"limit": 100,
},
headers={"X-Session-API-Key": session_key},
timeout=30.0,
)
response.raise_for_status()
page = response.json()
items = page.get("items") if isinstance(page, dict) else None
if not isinstance(items, list):
return None
return latest_finish_tool_response_from_events(items)


async def fetch_latest_finish_tool_response_for_run(
Comment thread
malhotra5 marked this conversation as resolved.
run: AutomationRun,
conversation_id: str,
) -> Any | None:
"""Best-effort lookup of the latest raw FinishTool response for a run."""
try:
backend = get_backend(run)
async with httpx.AsyncClient(timeout=60.0) as client:
if backend.is_local_mode:
ctx = await backend.get_execution_context(client)
return await fetch_latest_finish_tool_response(
client,
ctx.agent_url,
ctx.session_key,
conversation_id,
)

if not run.sandbox_id:
return None

api_key = await backend.get_api_key()
result = await get_sandbox_agent_url(
client,
get_config().service.openhands_api_base_url,
api_key,
run.sandbox_id,
)
if result is None:
return None
agent_url, session_key = result
return await fetch_latest_finish_tool_response(
client,
agent_url,
session_key,
conversation_id,
)
except Exception as exc:
logger.warning(
"Could not fetch finish tool response for run %s conversation %s: %s",
run.id,
conversation_id,
exc,
)
return None
Loading
Loading