Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5cdfb5f
feat(sdk): expose Agent Server control and runtime clients
openhands-agent Sep 12, 2026
34cd7ec
refactor(sdk): initialize orchestration transports on first request
openhands-agent Sep 12, 2026
79021c6
fix(sdk): reject empty explicit runtime scope
openhands-agent Sep 12, 2026
3509fa7
test(sdk): keep parametrized client scopes stable across workers
openhands-agent Sep 12, 2026
df0f25d
Merge remote-tracking branch 'origin/main' into factory/ready-sdk-5010
openhands-agent Sep 12, 2026
334cf4a
chore: Remove PR-only artifacts [automated]
Sep 12, 2026
dfb537c
fix: expose runtime prefix adapter on synchronous client
openhands-agent Sep 12, 2026
d41ab13
Merge remote-tracking branch 'origin/feat/agent-server-python-client'…
openhands-agent Sep 12, 2026
85b8bc7
feat(client): preserve plugin configuration on profile launches
openhands-agent Sep 13, 2026
252417d
feat: expose the existing final agent response through both clients
openhands-agent Sep 13, 2026
7c0fd01
fix: set conversation titles through the existing update endpoint
openhands-agent Sep 13, 2026
ed0112b
Merge remote-tracking branch 'origin/main' into factory/ready-sdk-5010
openhands-agent Sep 13, 2026
6573d2d
feat: attach to an existing server-resolved conversation
openhands-agent Sep 13, 2026
d76aa33
refactor(client): share event search and title operations
openhands-agent Sep 13, 2026
34ef2b2
fix: use server event kind for client error lookup
openhands-agent Sep 13, 2026
fb23e8a
docs: record live Canvas failure and client error comparison
openhands-agent Sep 13, 2026
780a4fb
chore: Remove PR-only artifacts [automated]
Sep 13, 2026
1f453fe
refactor(sdk): reuse conversations and workspaces for automation
openhands-agent Sep 13, 2026
4204b7e
fix(sdk): scope downloads and Git workspace operations
openhands-agent Sep 13, 2026
a2c0cf2
refactor(sdk): share profile and explicit-agent creation
openhands-agent Sep 13, 2026
b56bd00
Merge remote-tracking branch 'origin/main' into factory/ready-sdk-5010
openhands-agent Sep 13, 2026
70c3afe
refactor(sdk): make remote creation and attachment explicit
openhands-agent Sep 13, 2026
4bbab2d
fix(sdk): omit null fields in conversation creation requests
openhands-agent Sep 13, 2026
de5bc17
refactor(sdk): use the canonical conversation route
openhands-agent Sep 13, 2026
e9392e7
Merge main with conversation-scoped runtime APIs
openhands-agent Sep 14, 2026
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
11 changes: 11 additions & 0 deletions .pr/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Agent Server orchestration clients

The factory exposed a gap between the high-level Conversation/Workspace interfaces and trusted control-plane operations: saved-profile creation, selected-runtime credentials, release preserving history, and nonblocking command dispatch. Consumers currently encode these HTTP contracts themselves.

Add public sync/async AgentServerClient and scoped RuntimeClient interfaces in the SDK. Share operation construction in one internal module, so synchronous and asynchronous callers use identical routes, parameters, and auth. Keep scheduling, admission, retry policy, workflow prompts, and acceptance decisions in their owning consumers. Retain existing Conversation/Workspace behavior without migration.

Server-level metadata and conversation creation remain global. A runtime binds a validated conversation UUID and exposes named methods rather than arbitrary URL access. Explicit legacy host-runtime access supports existing dispatch consumers; lifecycle methods reject that unscoped form. A narrow API-prefix migration helper validates old caller state rather than trusting arbitrary paths.

Response objects retain additive server fields. The credential accessor validates a nonempty key. Release treats 404 as already released but propagates stop failures. Injected HTTP clients stay caller-owned; otherwise close/aclose releases the client pool.

Server contract review precedes this consumer: #4966/#3403, #4998/#5005, #5008. The client is an additive main-based PR; it changes none of those server implementations. Automation and extension consumers will use these methods and remove raw runtime HTTP code.
16 changes: 16 additions & 0 deletions openhands-sdk/openhands/sdk/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Public clients for Agent Server control and conversation-scoped runtimes."""

from openhands.sdk.client.agent_server import (
AgentServerClient,
AsyncAgentServerClient,
AsyncRuntimeClient,
RuntimeClient,
)


__all__ = [
"AgentServerClient",
"AsyncAgentServerClient",
"AsyncRuntimeClient",
"RuntimeClient",
]
135 changes: 135 additions & 0 deletions openhands-sdk/openhands/sdk/client/_requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Wire contracts shared by synchronous and asynchronous Agent Server clients."""

from dataclasses import dataclass, field
from typing import Any
from uuid import UUID


@dataclass(frozen=True)
class Operation:
method: str
path: str
options: dict[str, Any] = field(default_factory=dict)
allowed_statuses: frozenset[int] = frozenset()


def conversation_path(conversation_id: str) -> str:
return f"/api/conversations/{UUID(conversation_id)}"


def create_conversation(
*,
conversation_id: str,
agent_profile_id: str,
working_dir: str,
title: str,
max_iterations: int = 160,
tags: dict[str, str] | None = None,
) -> Operation:
return Operation(
"POST",
"/api/conversations",
{
"json": {
"conversation_id": str(UUID(conversation_id)),
"agent_profile_id": str(UUID(agent_profile_id)),
"workspace": {"kind": "LocalWorkspace", "working_dir": working_dir},
"title": title,
Comment thread
neubig marked this conversation as resolved.
Outdated
"max_iterations": max_iterations,
"tags": tags or {},
},
"timeout": 180,
},
)


def message(conversation_id: str, text: str, run: bool) -> Operation:
return Operation(
"POST",
conversation_path(conversation_id) + "/events",
{
"json": {"content": [{"type": "text", "text": text}], "run": run},
},
)


def errors(conversation_id: str, limit: int) -> Operation:
if not 1 <= limit <= 100:
raise ValueError("Error page limit must be between 1 and 100")
return Operation(
"GET",
conversation_path(conversation_id) + "/events/search",
{
"params": {
"kind": "ConversationErrorEvent",
"sort_order": "TIMESTAMP_DESC",
"limit": limit,
},
},
)


class RuntimeRequests:
"""A runtime scope cannot be changed by an operation's arguments."""

def __init__(self, conversation_id: str | None):
# None is the explicit legacy host runtime, not an automatic fallback.
self.api_prefix = (
conversation_path(conversation_id)
if conversation_id is not None
else "/api"
)

@classmethod
def from_api_prefix(cls, prefix: str) -> "RuntimeRequests":
"""Compatibility for consumers migrating from a stored API prefix."""
if prefix == "/api":
return cls(None)
root = "/api/conversations/"
if not prefix.startswith(root):
raise ValueError("Expected a conversation runtime scope")
return cls(prefix[len(root) :])

def upload(self, path: str, content: bytes, filename: str) -> Operation:
return Operation(
"POST",
self.api_prefix + "/file/upload",
{
"params": {"path": path},
"files": {"file": (filename, content)},
},
)

def bash(
self, command: str, timeout: int, *, background: bool, cwd: str | None = None
) -> Operation:
payload: dict[str, Any] = {"command": command, "timeout": timeout}
if cwd is not None:
payload["cwd"] = cwd
operation = "start_bash_command" if background else "execute_bash_command"
return Operation(
"POST",
self.api_prefix + "/bash/" + operation,
{
"json": payload,
"timeout": 90 if background else timeout + 30,
},
)

def output(self, command_id: str | None) -> Operation:
params = {"kind__eq": "BashOutput", "sort_order": "TIMESTAMP_DESC", "limit": 1}
if command_id is not None:
params["command_id__eq"] = command_id
return Operation(
"GET", self.api_prefix + "/bash/bash_events/search", {"params": params}
)

def lifecycle(self, *, credentials: bool) -> Operation:
if self.api_prefix == "/api":
raise ValueError("Lifecycle operations require a conversation runtime")
return Operation(
"POST" if credentials else "DELETE",
self.api_prefix + ("/runtime/credentials" if credentials else "/runtime"),
{"timeout": 60},
frozenset() if credentials else frozenset({404}),
)
Loading
Loading