Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 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
eacdfd1
feat(agent-server): add secure Docker conversation runtimes and recovery
openhands-agent Sep 12, 2026
9358e4d
fix: prevent broker refresh starvation and clean up WebSocket pumps
openhands-agent Sep 12, 2026
649de6a
fix(docker): let Docker allocate loopback ports atomically
openhands-agent Sep 12, 2026
2d3eb2b
fix: address docker review regressions
openhands-agent Sep 12, 2026
cd512dc
fix(docker): validate supported forwarded environment names
openhands-agent Sep 12, 2026
954892d
refactor(client): bind runtime services to explicit conversation owne…
openhands-agent Sep 12, 2026
bda334f
Merge explicit RuntimeClient foundation into Docker runtime
openhands-agent Sep 12, 2026
29184d8
fix(client): keep runtime URL parameters within public type budget
openhands-agent Sep 12, 2026
07e815b
Merge runtime client public type correction
openhands-agent Sep 12, 2026
c1b3866
docs(code): note FastMCP refresh-only compatibility boundary
openhands-agent Sep 12, 2026
f010bfa
docs: demonstrate real Docker runtime in Agent Canvas
openhands-agent Sep 12, 2026
19f49a2
fix(agent-server): surface docker startup failures
openhands-agent Sep 12, 2026
70b64d0
feat(agent-server): expose conversation runtime lifecycle
openhands-agent Sep 12, 2026
64f0629
test(agent-server): configure runtime registry encryption
openhands-agent Sep 12, 2026
1d15a06
feat(agent-server): add explicit conversation archiving
openhands-agent Sep 12, 2026
066231f
chore: Remove PR-only artifacts [automated]
Sep 12, 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]
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
Binary file added .pr/agent-canvas-docker.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions clients/typescript/src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2285,6 +2285,10 @@ describe('Auxiliary API clients', () => {
await client.respondToConfirmation('c1', { accept: true });
await client.deleteConversation('c1');
await client.updateConversation('c1', { title: 'New title' });
await client.getRuntime('c1');
await client.reprovisionRuntime('c1');
await client.archiveConversation('c1');
await client.unarchiveConversation('c1');

expect(global.fetch).toHaveBeenNthCalledWith(
1,
Expand Down Expand Up @@ -2314,6 +2318,26 @@ describe('Auxiliary API clients', () => {
'http://example.com/api/conversations/c1',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: 'New title' }) })
);
expect(global.fetch).toHaveBeenNthCalledWith(
9,
'http://example.com/api/conversations/c1/runtime',
expect.objectContaining({ method: 'GET' })
);
expect(global.fetch).toHaveBeenNthCalledWith(
10,
'http://example.com/api/conversations/c1/runtime/reprovision',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(global.fetch).toHaveBeenNthCalledWith(
11,
'http://example.com/api/conversations/c1/archive',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(global.fetch).toHaveBeenNthCalledWith(
12,
'http://example.com/api/conversations/c1/unarchive',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
});

it('ConversationClient wraps SDK v1.23.0 conversation endpoints', async () => {
Expand Down
133 changes: 133 additions & 0 deletions clients/typescript/src/__tests__/conversation-create-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { createServer, Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { ConversationClient } from '../client/conversation-client';

describe('conversation create response loss', () => {
let server: Server;
let host: string;
let creates: number;
let exists: boolean;
let reject: boolean;
let saveBeforeDrop: boolean;
let reads: number;
let readStatus: number;
beforeEach(async () => {
creates = 0;
readStatus = 404;
exists = false;
reject = false;
saveBeforeDrop = true;
reads = 0;
server = createServer((req, res) => {
if (req.method === 'POST') {
creates++;
req.resume();
req.on('end', () => {
if (reject) {
res.writeHead(400, { 'content-type': 'application/json' });
res.end(JSON.stringify({ detail: 'invalid settings' }));
} else {
exists = saveBeforeDrop;
req.socket.destroy();
}
});
} else {
reads++;
res.writeHead(exists ? 200 : readStatus, { 'content-type': 'application/json' });
res.end(JSON.stringify(exists ? { id: 'test-cid' } : { detail: 'not found' }));
}
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
host = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('recovers the existing conversation without repeating its initial message', async () => {
const result = await new ConversationClient({ host }).createConversation({
conversation_id: 'test-cid',
initial_message: { content: 'run once' },
});
expect(result.id).toBe('test-cid');
expect(creates).toBe(1);
});
it('does not conceal server validation failures', async () => {
reject = true;
await expect(
new ConversationClient({ host }).createConversation({
conversation_id: 'test-cid',
})
).rejects.toMatchObject({ status: 400 });
expect(creates).toBe(1);
expect(reads).toBe(0);
});
it('preserves the transport error if no conversation was saved', async () => {
saveBeforeDrop = false;
await expect(
new ConversationClient({ host, creationRecoveryTimeout: 100 }).createConversation({
conversation_id: 'test-cid',
})
).rejects.toThrow('Unknown request error');
expect(creates).toBe(1);
expect(reads).toBeGreaterThanOrEqual(1);
});
it('stops recovery on an authorization failure', async () => {
saveBeforeDrop = false;
readStatus = 401;
const started = Date.now();
await expect(
new ConversationClient({ host, creationRecoveryTimeout: 1000 }).createConversation({
conversation_id: 'test-cid',
})
).rejects.toThrow('Unknown request error');
expect(reads).toBe(1);
expect(creates).toBe(1);
expect(Date.now() - started).toBeLessThan(1000);
});
it('does not guess a conversation id when the caller supplied none', async () => {
await expect(new ConversationClient({ host }).createConversation({})).rejects.toThrow(
'Unknown request error'
);
expect(creates).toBe(1);
expect(reads).toBe(0);
});
});

describe('cold creation after client timeout', () => {
it('recovers when startup finishes after the first reconciliation read', async () => {
let ready = false;
let creates = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const server = createServer((req, res) => {
if (req.method === 'POST') {
creates++;
req.resume();
req.on('end', () => {
timer = setTimeout(() => {
ready = true;
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ id: 'cold-cid' }));
}, 200);
});
} else {
res.writeHead(ready ? 200 : 404, { 'content-type': 'application/json' });
res.end(JSON.stringify(ready ? { id: 'cold-cid' } : { detail: 'starting' }));
}
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const host = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
await expect(
new ConversationClient({ host, timeout: 50 }).createConversation({
conversation_id: 'cold-cid',
})
).resolves.toMatchObject({ id: 'cold-cid' });
expect(creates).toBe(1);
} finally {
if (timer) clearTimeout(timer);
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
Loading
Loading