Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
25 changes: 25 additions & 0 deletions migrations/versions/015_add_run_task_outcome.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Add task_outcome column to automation_runs table.

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

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op


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


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


def downgrade() -> None:
op.drop_column("automation_runs", "task_outcome")
2 changes: 2 additions & 0 deletions openhands/automation/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ async def _fail(error: str, disable: bool = False) -> None:
env_vars = backend.build_env_vars()
env_vars["AUTOMATION_CALLBACK_URL"] = callback_url
env_vars["AUTOMATION_RUN_ID"] = run_id
env_vars["AUTOMATION_CALLBACK_SUPPORTS_AGENT_OUTCOME"] = "true"

env_vars["AUTOMATION_USER_ID"] = str(automation.user_id)
env_vars["AUTOMATION_ORG_ID"] = str(automation.org_id)
env_vars["AUTOMATION_API_URL"] = settings.resolved_base_url
Expand Down
3 changes: 3 additions & 0 deletions openhands/automation/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,9 @@ async def run_automation(
env_vars["AUTOMATION_CALLBACK_URL"] = callback_url
if run_id:
env_vars["AUTOMATION_RUN_ID"] = run_id
if callback_url:
env_vars["AUTOMATION_CALLBACK_SUPPORTS_AGENT_OUTCOME"] = "true"

api_url = api_url.rstrip("/")
sandbox_id: str | None = None

Expand Down
5 changes: 5 additions & 0 deletions openhands/automation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ class AutomationRun(Base):
# force-terminated by the watchdog / cancelled so no callback ever fired.
cost: Mapped[float | None] = mapped_column(Float, nullable=True)

# Latest structured task outcome reported by the SDK/agent or recovered
# from the agent-server when the completion callback is missed.
# Uses generic JSON type for cross-database compatibility.
task_outcome: Mapped[dict | None] = mapped_column(JSON, nullable=True)

# Pre-computed deadline: started_at + max_duration. Set when transitioning
# to RUNNING, used by the staleness watchdog for efficient indexed queries.
timeout_at: Mapped[datetime | None] = mapped_column(
Expand Down
11 changes: 4 additions & 7 deletions openhands/automation/presets/plugin/sdk_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,8 @@
# may be missing here even when the agent-server has a valid session key.
# We still fall back to SESSION_API_KEY for compatibility with cloud-mode
# deployments and older agent-server versions that only set the bare name.
session_key = (
os.environ.get("OH_SESSION_API_KEYS_0")
or os.environ.get("SESSION_API_KEY", "")
session_key = os.environ.get("OH_SESSION_API_KEYS_0") or os.environ.get(
"SESSION_API_KEY", ""
)
model_profile = os.environ.get("AUTOMATION_MODEL") or None
automation_user_id = os.environ.get("AUTOMATION_USER_ID") or None
Expand All @@ -100,10 +99,7 @@
if IS_LOCAL_MODE:
# Local mode: AGENT_SERVER_URL required
print(f" AGENT_SERVER_URL: {'OK' if agent_server_url else 'MISSING'}")
print(
f" OH_SESSION_API_KEYS_0: "
f"{'OK' if session_key else 'NONE (may fail auth)'}"
)
print(f" OH_SESSION_API_KEYS_0: {'OK' if session_key else 'NONE (may fail auth)'}")
if not agent_server_url:
print("FAIL: AGENT_SERVER_URL not set for local mode", file=sys.stderr)
sys.exit(1)
Expand Down Expand Up @@ -449,6 +445,7 @@ def event_callback(event) -> None:
conversation = Conversation(**conversation_kwargs)
assert isinstance(conversation, RemoteConversation)
print(f" conversation created: {type(conversation).__name__}")
print(f" conversation id: {conversation.id}")
print(f" plugins loaded: {len(plugin_sources)}")
if experiment_tags:
print(f" experiment tags: {experiment_tags}")
Expand Down
11 changes: 4 additions & 7 deletions openhands/automation/presets/prompt/sdk_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,8 @@
# may be missing here even when the agent-server has a valid session key.
# We still fall back to SESSION_API_KEY for compatibility with cloud-mode
# deployments and older agent-server versions that only set the bare name.
session_key = (
os.environ.get("OH_SESSION_API_KEYS_0")
or os.environ.get("SESSION_API_KEY", "")
session_key = os.environ.get("OH_SESSION_API_KEYS_0") or os.environ.get(
"SESSION_API_KEY", ""
)
model_profile = os.environ.get("AUTOMATION_MODEL") or None
automation_user_id = os.environ.get("AUTOMATION_USER_ID") or None
Expand All @@ -103,10 +102,7 @@
if IS_LOCAL_MODE:
# Local mode: AGENT_SERVER_URL required
print(f" AGENT_SERVER_URL: {'OK' if agent_server_url else 'MISSING'}")
print(
f" OH_SESSION_API_KEYS_0: "
f"{'OK' if session_key else 'NONE (may fail auth)'}"
)
print(f" OH_SESSION_API_KEYS_0: {'OK' if session_key else 'NONE (may fail auth)'}")
if not agent_server_url:
print("FAIL: AGENT_SERVER_URL not set for local mode", file=sys.stderr)
sys.exit(1)
Expand Down Expand Up @@ -401,6 +397,7 @@ def event_callback(event) -> None:
conversation = Conversation(**conversation_kwargs)
assert isinstance(conversation, RemoteConversation)
print(f" conversation created: {type(conversation).__name__}")
print(f" conversation id: {conversation.id}")

# Set a descriptive title so automation runs are distinguishable in the
# conversation panel — otherwise the agent-server's autotitle falls back
Expand Down
6 changes: 6 additions & 0 deletions openhands/automation/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,12 @@ async def complete_run(
values["conversation_id"] = body.conversation_id
if body.cost is not None:
values["cost"] = body.cost
task_outcome = (
body.agent_outcome if body.agent_outcome is not None else body.task_outcome
)
if task_outcome is not None:
values["task_outcome"] = task_outcome

if body.status == "FAILED" and body.error:
values["error_detail"] = body.error

Expand Down
10 changes: 10 additions & 0 deletions openhands/automation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,14 @@ class RunCompleteRequest(BaseModel):
conversation_id: str | None = None
error: str | None = None
cost: float | None = None
agent_outcome: dict[str, Any] | None = Field(
default=None,
description="Latest SDK task outcome reported in the completion callback.",
)
task_outcome: dict[str, Any] | None = Field(
default=None,
description="Alias accepted for clients that send task_outcome directly.",
)


class AutomationRunResponse(BaseModel):
Expand All @@ -664,6 +672,8 @@ class AutomationRunResponse(BaseModel):
error_detail: str | None
conversation_id: str | None
cost: float | None = None
task_outcome: dict[str, Any] | None = None

timeout_at: UtcDatetime | None
sandbox_id: str | None
bash_command_id: str | None = None
Expand Down
55 changes: 55 additions & 0 deletions openhands/automation/utils/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"""

import logging
import re
from typing import Any

import httpx
from pydantic.dataclasses import dataclass
Expand All @@ -15,6 +17,49 @@

logger = logging.getLogger(__name__)

_UUID_RE = (
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)
_CONVERSATION_URL_RE = re.compile(rf"/conversations/(?P<id>{_UUID_RE})")
_CONVERSATION_ID_RE = re.compile(
rf"conversation(?:\s+id)?\s*:\s*(?P<id>{_UUID_RE})",
re.IGNORECASE,
)


def extract_conversation_id(output: str) -> str | None:
"""Extract a conversation id printed by automation preset scripts."""
for pattern in (_CONVERSATION_ID_RE, _CONVERSATION_URL_RE):
match = pattern.search(output)
if match:
return match.group("id")
return None


async def get_conversation_task_outcome(
client: httpx.AsyncClient,
agent_url: str,
session_key: str,
conversation_id: str,
) -> dict[str, Any] | None:
"""Fetch the latest task_outcome from an agent-server conversation."""
try:
resp = await client.get(
f"{agent_url.rstrip('/')}/api/conversations/{conversation_id}",
headers={"X-Session-API-Key": session_key},
timeout=30.0,
)
resp.raise_for_status()
outcome = resp.json().get("task_outcome")
return outcome if isinstance(outcome, dict) else None
except Exception as e:
logger.warning(
"Failed to fetch task outcome for conversation %s: %s",
conversation_id,
e,
)
return None


@dataclass(frozen=True)
class BashCommandResult:
Expand Down Expand Up @@ -112,6 +157,8 @@ class VerificationResult:
stdout: str = ""
stderr: str = ""
error: str | None = None
conversation_id: str | None = None
task_outcome: dict[str, Any] | None = None


async def verify_run_on_agent_server(
Expand Down Expand Up @@ -168,6 +215,12 @@ async def verify_run_on_agent_server(
)

success = bash_result.exit_code == 0
conversation_id = extract_conversation_id(bash_result.stdout)
task_outcome = None
if conversation_id:
task_outcome = await get_conversation_task_outcome(
client, agent_url, session_key, conversation_id
)
logger.info(
"Verified run status: exit_code=%s, success=%s",
bash_result.exit_code,
Expand All @@ -181,4 +234,6 @@ async def verify_run_on_agent_server(
exit_code=bash_result.exit_code,
stdout=bash_result.stdout,
stderr=bash_result.stderr,
conversation_id=conversation_id,
task_outcome=task_outcome,
)
10 changes: 10 additions & 0 deletions openhands/automation/utils/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from openhands.automation.utils.agent_server import (
BashCommandResult,
VerificationResult,
extract_conversation_id,
get_conversation_task_outcome,
get_last_bash_command_result,
)
from openhands.automation.utils.log_context import log_extra
Expand Down Expand Up @@ -195,6 +197,12 @@ async def verify_run_status(
)

success = bash_result.exit_code == 0
conversation_id = extract_conversation_id(bash_result.stdout)
task_outcome = None
if conversation_id:
task_outcome = await get_conversation_task_outcome(
client, agent_url, session_key, conversation_id
)
logger.info(
"Verified run status: exit_code=%s, success=%s",
bash_result.exit_code,
Expand All @@ -208,4 +216,6 @@ async def verify_run_status(
exit_code=bash_result.exit_code,
stdout=bash_result.stdout,
stderr=bash_result.stderr,
conversation_id=conversation_id,
task_outcome=task_outcome,
)
10 changes: 10 additions & 0 deletions openhands/automation/watchdog.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import asyncio
import logging
from typing import Any

from sqlalchemy import inspect, select, update
from sqlalchemy.engine import CursorResult
Expand Down Expand Up @@ -117,6 +118,12 @@ async def _verify_and_mark_run(
return result.rowcount > 0

if verification.verified:
verification_values: dict[str, Any] = {}
if verification.conversation_id:
verification_values["conversation_id"] = verification.conversation_id
if verification.task_outcome is not None:
verification_values["task_outcome"] = verification.task_outcome

exit_code = verification.exit_code

# exit_code == 0: Command completed successfully, we just missed the callback
Expand All @@ -136,6 +143,7 @@ async def _verify_and_mark_run(
.values(
status=AutomationRunStatus.COMPLETED,
completed_at=now,
**verification_values,
)
)

Expand All @@ -160,6 +168,7 @@ async def _verify_and_mark_run(
status=AutomationRunStatus.FAILED,
completed_at=now,
error_detail=f"Timed out: {error_msg}",
**verification_values,
)
)

Expand Down Expand Up @@ -187,6 +196,7 @@ async def _verify_and_mark_run(
status=AutomationRunStatus.FAILED,
completed_at=now,
error_detail=error_detail,
**verification_values,
)
)

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ dependencies = [
"google-cloud-storage>=2.18",
"httpx>=0.27",
"jmespath>=1.0",
"openhands-sdk==1.40.1",
"openhands-workspace==1.40.1",
"openhands-sdk @ git+https://github.com/OpenHands/software-agent-sdk.git@be17960b7846fdfc4bd66ce2fb6dd40bd890f6b3#subdirectory=openhands-sdk",
"openhands-workspace @ git+https://github.com/OpenHands/software-agent-sdk.git@be17960b7846fdfc4bd66ce2fb6dd40bd890f6b3#subdirectory=openhands-workspace",
"pg8000>=1.31",
"prometheus-client>=0.19",
"pydantic>=2",
Expand Down
2 changes: 2 additions & 0 deletions tests/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ def test_migrations_run_on_sqlite(self, monkeypatch):
}
assert "cost" in run_columns

assert "task_outcome" in run_columns

engine.dispose()
finally:
# Clean up
Expand Down
Loading
Loading