Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
20b83ba
feat: add conversation-scoped runtime APIs and clients
openhands-agent Sep 12, 2026
f638e0e
fix(ci): recognize upstream approved LLM.modify_params removal
openhands-agent Sep 12, 2026
954892d
refactor(client): bind runtime services to explicit conversation owne…
openhands-agent Sep 12, 2026
29184d8
fix(client): keep runtime URL parameters within public type budget
openhands-agent Sep 12, 2026
c12cf0a
Merge main into conversation runtime API foundation
openhands-agent Sep 12, 2026
584aa4a
feat: expose runtime lifecycle contracts independently of Docker
openhands-agent Sep 13, 2026
f64e34f
chore: keep runtime lifecycle tests focused
openhands-agent Sep 13, 2026
ae05f6e
Merge branch 'factory/restack-lifecycle' into factory/ready-sdk-4966
openhands-agent Sep 13, 2026
ad08608
style: format extracted runtime lifecycle model
openhands-agent Sep 13, 2026
b8e0d3c
ci: use the native stack base instead of temporary branch filters
openhands-agent Sep 13, 2026
c3d8db9
Merge remote-tracking branch 'origin/main' into factory/ready-sdk-4966
openhands-agent Sep 13, 2026
d5719d4
docs: capture live Canvas evidence for #4966
openhands-agent Sep 13, 2026
86695aa
chore: Remove PR-only artifacts [automated]
Sep 13, 2026
2805ce4
refactor(agent-server): keep runtime contracts independent of Docker
openhands-agent Sep 13, 2026
3808768
Merge remote-tracking branch 'origin/main' into factory/ready-sdk-4966
openhands-agent Sep 13, 2026
4244f91
refactor(client): scope existing clients without runtime facades
openhands-agent Sep 14, 2026
3600b6d
fix(api): type download schemas on their owning routes
openhands-agent Sep 14, 2026
cd064c0
fix(runtime): limit process cleanup to scoped services
openhands-agent Sep 14, 2026
65911c8
docs: add live narrowed-client evidence
openhands-agent Sep 14, 2026
97e664a
refactor(runtime): keep route reuse compatibility explicit
openhands-agent Sep 14, 2026
9fd0983
refactor(runtime): remove unused scoped contracts
openhands-agent Sep 14, 2026
8152c39
refactor(client): rely on the compiled client contract
openhands-agent Sep 14, 2026
4b75798
docs: align runtime evidence with narrowed scope
openhands-agent Sep 14, 2026
72a74b3
docs(client): explain optional conversation ownership
openhands-agent Sep 14, 2026
c39586e
fix(runtime): keep host bash teardown unchanged
openhands-agent Sep 14, 2026
8182852
refactor(runtime): name conversation ownership explicitly
openhands-agent Sep 14, 2026
01398e7
refactor(runtime): make bash lifecycle intrinsic
openhands-agent Sep 14, 2026
1e1a610
chore: Remove PR-only artifacts [automated]
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
25 changes: 25 additions & 0 deletions .github/scripts/check_deprecations.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,31 @@ def _gather_rest_route_deprecations(
tree: ast.AST, path: Path, *, package: str
) -> Iterator[DeprecationRecord]:
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "add_api_route"
and isinstance(
deprecated_flag := _extract_kw(node, "deprecated"), ast.Constant
)
and deprecated_flag.value is True
):
identifier = ast.unparse(node.args[0]) if node.args else "<route>"
deprecated_in, removed_in = _parse_rest_route_deprecation_docstring(
_extract_string_literal(_extract_kw(node, "description")),
path=path,
line=node.lineno,
route_identifiers=[identifier],
)
yield DeprecationRecord(
identifier=identifier,
removed_in=removed_in,
deprecated_in=deprecated_in,
path=path,
line=node.lineno,
kind="rest_route",
package=package,
)
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
continue

Expand Down
25 changes: 16 additions & 9 deletions .github/scripts/check_sdk_api_breakage.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,20 @@ class FieldDefaultChange:
DEPRECATION_RUNWAY_MINOR_RELEASES = 5
FIELD_DEFAULT_CHANGE_REPORT_ENV = "SDK_API_BREAKAGE_REPORT_PATH"

_ACCEPTED_REMOVED_MEMBERS: frozenset[tuple[str, str]] = frozenset(
{
("openhands.workspace", "DockerDevWorkspace.mount_dir"),
("openhands.workspace", "DockerWorkspace.mount_dir"),
}
)
_ACCEPTED_REMOVED_MEMBERS: dict[tuple[str, str], str] = {
("openhands.workspace", "DockerDevWorkspace.mount_dir"): (
"Maintainers explicitly accepted this long-deprecated API break in "
"PR #3822, and that PR is labeled release-note-required."
),
("openhands.workspace", "DockerWorkspace.mount_dir"): (
"Maintainers explicitly accepted this long-deprecated API break in "
"PR #3822, and that PR is labeled release-note-required."
),
("openhands.sdk", "LLM.modify_params"): (
"Removed upstream in PR #4954 after its v1.42.0 to v1.47.0 "
"deprecation runway; legacy persisted settings are migrated on load."
),
}


def _is_accepted_removed_member(package: str, feature: str) -> bool:
Expand Down Expand Up @@ -787,9 +795,8 @@ def _collect_breakages_pairs(
if emit_diagnostics:
print(
f"::notice title={title}::Accepted removal of "
f"{feature}. Maintainers explicitly accepted "
"this long-deprecated API break in PR #3822, "
"and that PR is labeled release-note-required."
f"{feature}. "
f"{_ACCEPTED_REMOVED_MEMBERS[package, feature]}"
)
continue

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/agent-server-rest-api-breakage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, feat/conversation-scoped-runtime-api]

jobs:
agent-server-rest-api:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/api-breakage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, feat/conversation-scoped-runtime-api]

jobs:
sdk-api:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/persisted-settings-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, feat/conversation-scoped-runtime-api]

jobs:
persisted-settings-compat:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/review-thread-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Review Thread Gate

on:
pull_request:
branches: [main]
branches: [main, feat/conversation-scoped-runtime-api]
types: [opened, synchronize, reopened, ready_for_review, edited]

permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, feat/conversation-scoped-runtime-api]
Comment thread
neubig marked this conversation as resolved.
Outdated
workflow_dispatch:
inputs:
base_image:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/typescript-client-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, feat/conversation-scoped-runtime-api]

defaults:
run:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/typescript-client-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, feat/conversation-scoped-runtime-api]
workflow_dispatch:
inputs:
llm_model:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/version-bump-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Version bump guard

on:
pull_request:
branches: [main]
branches: [main, feat/conversation-scoped-runtime-api]

jobs:
version-bump-guard:
Expand Down
71 changes: 71 additions & 0 deletions clients/typescript/src/__tests__/conversation-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createServer, Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { ServerClient } from '../client/server-client';
import { HttpClient } from '../client/http-client';
import { RemoteWorkspace } from '../workspace/remote-workspace';

describe('conversation-scoped requests', () => {
let server: Server;
let host: string;
const urls: string[] = [];
let scoped = false;
beforeAll(async () => {
server = createServer((req, res) => {
urls.push(req.url!);

Check warning on line 14 in clients/typescript/src/__tests__/conversation-scope.test.ts

View workflow job for this annotation

GitHub Actions / test (22.12)

Forbidden non-null assertion

Check warning on line 14 in clients/typescript/src/__tests__/conversation-scope.test.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Forbidden non-null assertion
if (req.url === '/server_info') {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ capabilities: scoped ? ['conversation_runtime_routes_v1'] : [] }));
return;
}
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ exit_code: 0, stdout: 'ok', stderr: '' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
host = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('advertises client-side runtime routing support', () => {
expect(ServerClient.supportsConversationRuntimeRoutes).toBe(true);
});
it('scopes workspace commands to their conversation', async () => {
const workspace = new RemoteWorkspace({
host,
workingDir: '/workspace',
conversationId: 'demo-cid',
});
const result = await workspace.executeCommand('pwd');
expect(result.stdout).toBe('ok');
expect(urls.pop()).toBe('/api/bash/execute_bash_command?cid=demo-cid');
});
it('uses canonical routes and preserves explicit context on capable servers', async () => {
scoped = true;
try {
const client = new HttpClient({ baseUrl: host, conversationId: 'default' });
await client.get('/api/file/download', { params: { cid: 'explicit', path: '/workspace/a' } });
expect(urls.pop()).toBe('/api/conversations/explicit/file/download?path=%2Fworkspace%2Fa');
await client.post('/api/bash/execute_bash_command', { command: 'pwd' });
expect(urls.pop()).toBe('/api/conversations/default/bash/execute_bash_command');
await client.get('/api/tools/');
expect(urls.pop()).toBe('/api/tools/');
await client.get('/api/file/home');
expect(urls.pop()).toBe('/api/file/home');
await client.post('/api/mcp/test', { server: {} });
expect(urls.pop()).toBe('/api/conversations/default/mcp/test');
await client.get('/api/mcp/oauth/status/job');
expect(urls.pop()).toBe('/api/mcp/oauth/status/job');
} finally {
scoped = false;
}
});
it('preserves unscoped behavior and explicit query context', async () => {
await new HttpClient({ baseUrl: host }).get('/api/file/home');
expect(urls.pop()).toBe('/api/file/home');
await new HttpClient({ baseUrl: host, conversationId: 'default' }).get('/api/file/download', {
params: { cid: 'explicit' },
});
expect(urls.pop()).toBe('/api/file/download?cid=explicit');
});
});
2 changes: 2 additions & 0 deletions clients/typescript/src/client/bash-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {

export interface BashClientOptions {
host: string;
conversationId?: string;
apiKey?: string;
timeout?: number;
}
Expand All @@ -25,6 +26,7 @@ export class BashClient {
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
conversationId: options.conversationId,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
Expand Down
2 changes: 2 additions & 0 deletions clients/typescript/src/client/desktop-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DesktopUrlResponse } from '../models/api';

export interface DesktopClientOptions {
host: string;
conversationId?: string;
apiKey?: string;
timeout?: number;
}
Expand All @@ -17,6 +18,7 @@ export class DesktopClient {
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
conversationId: options.conversationId,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
Expand Down
2 changes: 2 additions & 0 deletions clients/typescript/src/client/file-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Success } from '../types/base';

export interface FileClientOptions {
host: string;
conversationId?: string;
apiKey?: string;
timeout?: number;
}
Expand All @@ -25,6 +26,7 @@ export class FileClient {
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
conversationId: options.conversationId,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
Expand Down
29 changes: 29 additions & 0 deletions clients/typescript/src/client/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

export interface HttpClientOptions {
baseUrl: string;
conversationId?: string;
apiKey?: string;
timeout?: number;
}
Expand Down Expand Up @@ -48,16 +49,25 @@ export class HttpError extends Error {
}

export class HttpClient {
private conversationId?: string;
private baseUrl: string;
private apiKey?: string;
private timeout: number;
private runtimeRoutes?: Promise<boolean>;

constructor(options: HttpClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.conversationId = options.conversationId;
this.timeout = options.timeout || 60000;
}

private isRuntimePath(path: string): boolean {
if (path === '/api/mcp/test') return true;
if (path === '/api/file/home' || path === '/api/file/search_subdirs') return false;
return /^\/api\/(bash|file|git|desktop|vscode)(\/|$)/.test(path);
}

private buildUrl(path: string, params?: Record<string, unknown>): URL {
const relativePath = path.startsWith('/') ? path.slice(1) : path;
const url = new URL(relativePath, this.baseUrl + '/');
Expand All @@ -74,10 +84,29 @@ export class HttpClient {
});
}

if (this.conversationId && this.isRuntimePath(path) && !url.searchParams.has('cid')) {
url.searchParams.set('cid', this.conversationId);
}
return url;
}

async request<T = unknown>(options: RequestOptions): Promise<HttpResponse<T>> {
if (this.conversationId && this.isRuntimePath(options.url)) {
this.runtimeRoutes ??= this.get<{ capabilities?: string[] }>('/server_info')
.then(({ data }) => data.capabilities?.includes('conversation_runtime_routes_v1') ?? false)
.catch((error: unknown) => {
if (error instanceof HttpError && error.status === 404) return false;
this.runtimeRoutes = undefined;
throw error;
});
if (await this.runtimeRoutes) {
options = {
...options,
url: `/api/conversations/${encodeURIComponent(String(options.params?.cid ?? this.conversationId))}${options.url.slice(4)}`,
params: { ...options.params, cid: undefined },
};
}
}
// `fetch` (and browsers) reject a body on a GET request, but a few
// agent-server batch endpoints (e.g. `GET /api/bash/bash_events/` and
// `GET /api/conversations/{id}/events`) are declared as GET-with-required-body.
Expand Down
2 changes: 2 additions & 0 deletions clients/typescript/src/client/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {

export interface MCPClientOptions {
host: string;
conversationId?: string;
apiKey?: string;
timeout?: number;
}
Expand All @@ -34,6 +35,7 @@ export class MCPClient {
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
conversationId: options.conversationId,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
Expand Down
1 change: 1 addition & 0 deletions clients/typescript/src/client/server-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ServerClientOptions {
}

export class ServerClient {
public static readonly supportsConversationRuntimeRoutes = true;
public readonly host: string;
public readonly apiKey?: string;
private readonly client: HttpClient;
Expand Down
2 changes: 2 additions & 0 deletions clients/typescript/src/client/vscode-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { VSCodeStatusResponse, VSCodeUrlResponse } from '../models/api';

export interface VSCodeClientOptions {
host: string;
conversationId?: string;
apiKey?: string;
timeout?: number;
}
Expand All @@ -22,6 +23,7 @@ export class VSCodeClient {
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
conversationId: options.conversationId,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
Expand Down
6 changes: 5 additions & 1 deletion clients/typescript/src/models/agent-server-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export type AgentServerSettingsPatchRequest = UpdateSettingsApiSettingsPatchData
export type AgentServerSettingsPatchResponse = UpdateSettingsApiSettingsPatchResponse;

export type AgentServerMCPTestRequest = TestMcpServerApiMcpTestPostData['body'];
export type AgentServerMCPTestResponse = TestMcpServerApiMcpTestPostResponse;
export type AgentServerMCPTestResponse = TestMcpServerApiMcpTestPostResponse & {
/** Absent on older servers. Host success does not verify a conversation runtime. */
scope?: 'host' | 'runtime';
runtime_verified?: boolean;
};
export type AgentServerMCPToolCall = McpToolCallSpec;
export type AgentServerMCPStartOAuthRequest = StartMcpOauthApiMcpOauthStartPostData['body'];
export type AgentServerMCPStartOAuthResponse = StartMcpOauthApiMcpOauthStartPostResponse;
Expand Down
4 changes: 4 additions & 0 deletions clients/typescript/src/models/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,8 @@ export interface MCPToolCallResult {
}

export interface MCPTestSuccess {
scope?: 'host' | 'runtime';
runtime_verified?: boolean;
ok: true;
tools: string[];
tool_result?: MCPToolCallResult | null;
Expand All @@ -602,6 +604,8 @@ export interface MCPTestSuccess {
export type MCPTestFailureKind = 'timeout' | 'connection' | 'unknown';

export interface MCPTestFailure {
scope?: 'host' | 'runtime';
runtime_verified?: boolean;
ok: false;
error: string;
error_kind: MCPTestFailureKind;
Expand Down
Loading
Loading