Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
210 changes: 210 additions & 0 deletions clients/typescript/src/__tests__/conversation-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { createServer, Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { ServerClient } from '../client/server-client';
import { ConversationManager } from '../conversation/conversation-manager';
import { FileClient } from '../client/file-client';
import { MCPClient } from '../client/mcp-client';
import { HttpClient } from '../client/http-client';
import { RuntimeClient } from '../client/runtime-client';
import type { AgentBase } from '../types/base';
import { RemoteWorkspace } from '../workspace/remote-workspace';

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

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

View workflow job for this annotation

GitHub Actions / test (24.x)

Forbidden non-null assertion

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

View workflow job for this annotation

GitHub Actions / test (22.12)

Forbidden non-null assertion
if (req.url === '/server_info') {
res.statusCode = discoveryStatus;
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
version: '1.47.0',
capabilities: scoped ? ['conversation_runtime_routes_v1'] : [],
})
);
return;
}
if (
req.url === '/api/conversations' ||
req.url === '/api/conversations/created' ||
req.url === '/api/conversations/created/fork'
) {
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
id: req.url.endsWith('/fork') ? 'forked' : 'created',
agent: { kind: 'Agent' },
workspace: { working_dir: '/workspace' },
})
);
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('keeps runtime services scoped and setup operations global', async () => {
scoped = true;
try {
const manager = new ConversationManager({ host });
const runtime = manager.runtime('selected');
await runtime.files.downloadFile('/workspace/a');
expect(urls.pop()).toBe('/api/conversations/selected/file/download?path=%2Fworkspace%2Fa');
await runtime.bash.executeCommand({ command: 'pwd' });
expect(urls.pop()).toBe('/api/conversations/selected/bash/execute_bash_command');
await manager.files.getHome();
expect(urls.pop()).toBe('/api/file/home');
await runtime.mcp.testServer({ server: { command: 'echo' } });
expect(urls.pop()).toBe('/api/conversations/selected/mcp/test');
await manager.mcp.getOAuthStatus('job');
expect(urls.pop()).toBe('/api/mcp/oauth/status/job');
} finally {
scoped = false;
}
});
it('preserves mixed-scope legacy constructors without classifying URLs', async () => {
scoped = true;
try {
const files = new FileClient({ host, conversationId: 'legacy' });
await files.getHome();
expect(urls.pop()).toBe('/api/file/home');
await files.downloadFile('/workspace/a');
expect(urls.pop()).toContain('/api/conversations/legacy/file/download');
const mcp = new MCPClient({ host, conversationId: 'legacy' });
await mcp.getOAuthStatus('job');
expect(urls.pop()).toBe('/api/mcp/oauth/status/job');
} finally {
scoped = false;
}
});
it('rejects attempts to override immutable runtime identity', async () => {
const runtime = new RuntimeClient({ host, conversationId: 'selected' });
await expect(runtime.url('/api/file/download', { cid: 'other' })).rejects.toThrow(
'cannot be overridden'
);
await expect(runtime.url('/api/file/../../settings')).rejects.toThrow('API path');
await expect(runtime.files.downloadTrajectory('other')).rejects.toThrow('selected runtime');
expect(() => new RuntimeClient({ host, conversationId: '' })).toThrow('conversation ID');
});
it('leaves generic HTTP requests untouched without discovery', async () => {
const start = urls.length;
await new HttpClient({ baseUrl: host }).get('/api/file/download', {
params: { cid: 'explicit' },
});
expect(urls.slice(start)).toEqual(['/api/file/download?cid=explicit']);
});
it('coalesces capability discovery across concurrent runtime requests', async () => {
scoped = true;
const start = urls.length;
const manager = new ConversationManager({ host });
const client = manager.runtime('shared');
const other = manager.runtime('other');
try {
await Promise.all([
client.git.changes('/workspace'),
other.files.downloadFile('/workspace/a'),
]);
expect(urls.slice(start).filter((url) => url === '/server_info')).toHaveLength(1);
expect(urls.slice(start)).toContain(
'/api/conversations/shared/git/changes?path=%2Fworkspace'
);
expect(urls.slice(start)).toContain(
'/api/conversations/other/file/download?path=%2Fworkspace%2Fa'
);
} finally {
scoped = false;
}
});
it('retries discovery after errors without silently downgrading', async () => {
discoveryStatus = 503;
const client = new RuntimeClient({ host, conversationId: 'retry' });
const start = urls.length;
try {
await expect(client.files.downloadFile('/workspace/a')).rejects.toMatchObject({
status: 503,
});
expect(urls.slice(start)).toEqual(['/server_info']);
discoveryStatus = 200;
scoped = true;
await client.files.downloadFile('/workspace/a');
expect(urls.pop()).toBe('/api/conversations/retry/file/download?path=%2Fworkspace%2Fa');
} finally {
discoveryStatus = 200;
scoped = false;
}
});
it('falls back when server info is unavailable on an older server', async () => {
discoveryStatus = 404;
try {
const client = new RuntimeClient({ host, conversationId: 'legacy' });
await client.files.downloadFile('/workspace/a');
expect(urls.pop()).toBe('/api/file/download?path=%2Fworkspace%2Fa&cid=legacy');
} finally {
discoveryStatus = 200;
}
});
it('shares the runtime instance with workspaces and keeps sessions global', async () => {
scoped = true;
try {
const runtime = new ConversationManager({ host }).runtime('preview');
const workspace = new RemoteWorkspace({ host, workingDir: '/workspace', runtime });
expect(workspace.bash).toBe(runtime.bash);
expect(workspace.client).toBe(runtime.transport);
expect(await workspace.startWorkspaceSession('preview')).toBe(
`${host}/api/conversations/preview/workspace/`
);
expect(urls.pop()).toBe('/api/auth/workspace-session');
await expect(workspace.startWorkspaceSession('other')).rejects.toThrow('selected runtime');
expect(await runtime.url('/api/file/download', { path: '/workspace/a' })).toBe(
`${host}/api/conversations/preview/file/download?path=%2Fworkspace%2Fa`
);
} finally {
scoped = false;
}
});
it('binds created, loaded and forked workspaces to their own runtimes', async () => {
scoped = true;
try {
const manager = new ConversationManager({ host });
const created = await manager.createConversation({ kind: 'Agent' } as AgentBase, {
workingDir: '/workspace',
});
expect(created.workspace.runtime?.conversationId).toBe('created');
await created.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/created/bash/execute_bash_command');
const loaded = await manager.loadConversation('created', '/workspace');
expect(loaded.workspace.connection).toBe(created.workspace.connection);
const forked = await created.fork();
expect(forked.workspace.runtime?.conversationId).toBe('forked');
await forked.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/forked/bash/execute_bash_command');
expect(created.workspace.runtime?.conversationId).toBe('created');
} finally {
scoped = false;
}
});
});
22 changes: 9 additions & 13 deletions clients/typescript/src/client/bash-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { HttpClient, HttpError } from './http-client';
import { HttpError } from './http-client';
import { runtimeServiceConnections } from './runtime-transport';
import type { RuntimeServiceOptions } from './runtime-transport';
import type { HttpClient } from './http-client';
import {
BashCommand,
BashEvent,
Expand All @@ -9,25 +12,18 @@ import {
ExecuteBashRequest,
} from '../models/workspace';

export interface BashClientOptions {
host: string;
apiKey?: string;
timeout?: number;
}
export type BashClientOptions = RuntimeServiceOptions;

export class BashClient {
public readonly host: string;
public readonly apiKey?: string;
private readonly client: HttpClient;

constructor(options: BashClientOptions) {
this.host = options.host.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
const { server, runtime } = runtimeServiceConnections(options);
this.host = server.host;
this.apiKey = server.sessionApiKey;
this.client = runtime;
}

async searchEvents(options: BashEventSearchOptions = {}): Promise<BashEventPage> {
Expand Down
Loading
Loading