chore: bump SDK deps (software-agent-sdk 1.44.0, automation 1.9.0, extensions 0.19.0) (#16968)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Hiep Le
2026-08-28 00:40:12 +07:00
committed by GitHub
parent d104ffdc33
commit 732f866ed6
15 changed files with 249 additions and 34 deletions
+4 -2
View File
@@ -249,6 +249,7 @@ you are running inside of — NOT the automation backend.
- `POST /admin/trajectory/register` — register a named trajectory (JSON body: `{name, turns}` where each turn is `{tool_call: {name, arguments}}` or `{text: "..."}`)
- `POST /admin/trajectory/activate` — activate a previously registered trajectory
- `GET /admin/requests` — return the list of all `/v1/chat/completions` request bodies captured since the last reset (used by the image-upload test to verify the image was forwarded to the LLM)
- Profile pre-flight ping: agent-server ≥ 1.43 sends a 1-token `ping` completion whenever an LLM profile is saved (`POST /api/profiles/{name}/validate`, 30 s budget in the canvas). The mock answers it with a canned `pong` instead of feeding it to `TestLLM`, so it neither consumes a scripted turn nor 500s-and-retries past the canvas timeout when the trajectory is exhausted; it is also left out of the `/admin/requests` history.
- **Real automation backend**: The automation test uses the production automation backend (started by `bin/agent-canvas.mjs`), NOT a mock server. Terminal `curl` commands from the agent hit the automation API through the ingress proxy at the test's `BACKEND_URL` (default `http://localhost:18300`). Auth uses the `X-Session-API-Key` header matching the stack's session key.
- **Test helpers** (`tests/e2e/mock-llm/utils/mock-llm-helpers.ts`): Exports `registerTrajectory()`, `activateTrajectory()`, `resetMockLLM()`, `ensureMockLLMProfile()`, `getMockLLMRequests()` (fetches captured completion bodies from `GET /admin/requests`), `IMAGE_REPLY_TOKEN` + `MINIMAL_PNG_BASE64` (constants for the image-upload spec), ACP helpers (`configureAcpAgent()`, `verifyAcpAgentSettings()`, `resetToOpenHandsAgent()`, `ACP_REPLY_TOKEN`, `MOCK_ACP_SERVER_PATH`), and more.
- **Padding response for internal LLM call**: The agent-server makes an internal LLM call (condenser/skill-analysis) before the agent's main loop starts when skills are activated. This consumes one trajectory response. Automation tests prepend a throwaway `{ text: "" }` response as padding. The conversation test does NOT need this because its user message doesn't trigger skill activation.
@@ -578,13 +579,14 @@ When adding code that needs a new string, decide up front which rule it falls un
- `scripts/dev-safe.mjs` uses `uvx` for temporary agent-server installation — no permanent `uv tool install` needed. Environment variables (highest precedence first):
- `OH_AGENT_SERVER_LOCAL_PATH` — absolute path to a local `software-agent-sdk` checkout. Runs the local checkout via `uvx` with `--with-editable` for `openhands-sdk`/`openhands-tools`/`openhands-workspace` and `--reinstall` for `openhands-agent-server`, so SDK edits are picked up on restart. Highest precedence.
- `OH_AGENT_SERVER_GIT_REF` — git commit SHA or branch name (takes precedence over version)
- `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.42.1")
- `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.44.0")
- `OH_SECRET_KEY` — secret key for settings encryption; auto-generated and persisted to `~/.openhands/agent-canvas/secret-key.txt` on first run (same file Docker uses), ensuring dev mode and Docker share the same key when both mount the same `~/.openhands` directory. Override with the env var to pin a specific key.
- `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0` / `VITE_SESSION_API_KEY` — session API key for agent-server authentication; auto-generated using `crypto.randomBytes(32)` if not set, passed to both agent-server (`OH_SESSION_API_KEYS_0`) and frontend (`VITE_SESSION_API_KEY`)
- Default: released PyPI version `1.42.1` for agent-server SDK libraries
- Default: released PyPI version `1.44.0` for agent-server SDK libraries
- Security: launchers generate and persist a 64-character session API key at `~/.openhands/agent-canvas/session-api-key.txt` unless overridden. The agent-server and automation backend share that session key. `OH_SECRET_KEY` protects settings encryption and is persisted separately at `~/.openhands/agent-canvas/secret-key.txt`.
- `scripts/dev-safe.mjs` should fail fast if `uvx` cannot be spawned (for example missing PATH entries).
- `tools/` holds Python modules the agent-server can import: `buildAgentServerEnv` exposes the directory through `OH_EXTRA_PYTHON_PATH` (Docker: `/opt/agent-canvas/tools`, see `docker/entrypoint.sh`). `tools/canvas_ui_tool.py` is imported at startup via `--import-modules canvas_ui_tool` (appended by `buildAgentServerCommand` and by both launch lines in `docker/entrypoint.sh`), not only lazily from persisted conversation metadata: besides the legacy `canvas_ui` registration it registers the SDK's builtin `FinishTool`, so `openhands-automation` ≥ 1.9.0 presets — which dispatch remote conversations with `finish_tool_response_schema=TaskOutcome` and advertise the tool as the non-self-registering `openhands.sdk.tool.builtins.finish` — do not fail every run with `ToolDefinition 'FinishTool' is not registered`. Remove that registration once the SDK registers builtins for remote conversations.
- `npm run dev` runs the full local stack via `uvx` (agent-server + automation backend + Vite dev server + ingress proxy) with no Docker dependency. `npm run dev:static` does the same but serves a production build of the frontend instead of the Vite dev server.
- `scripts/dev-with-automation.mjs` runs the full stack: agent-server, automation backend (both via uvx), frontend server, and ingress proxy. It defaults to Vite when run directly, supports `--static` for an existing build, and supports `--dynamic` so wrappers that default static can opt back into Vite. Uses a standalone ingress proxy (`scripts/ingress.mjs`) to route traffic:
- Keep `SIGINT`, `SIGTERM`, and `SIGHUP` wired through the coordinated shutdown handler. Services run in detached process groups on POSIX, so cleanup must use `signalProcessTree()` rather than signaling only the direct child; regression coverage lives in `__tests__/scripts/dev-with-automation.test.ts`.
@@ -71,8 +71,13 @@ describe("RecommendedAutomationsRail", () => {
"news-digest",
"slack-standup-digest",
"linear-triage-assistant",
"linear-issue-to-github-pr",
"linear-issue-to-gitlab-mr",
"linear-issue-to-bitbucket-pr",
"jira-issue-to-pr",
"jira-issue-to-gitlab-mr",
"research-brief-writer",
"jira-issue-to-bitbucket-pr",
]);
expect(
screen.getByText(I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_LABEL),
@@ -125,6 +130,11 @@ describe("RecommendedAutomationsRail", () => {
{ name: "Linear issue triage assistant" },
{ name: "Jira issue to GitHub PR" },
{ name: "Research brief writer" },
{ name: "Linear issue to GitHub PR" },
{ name: "Linear issue to GitLab MR" },
{ name: "Linear issue to Bitbucket PR" },
{ name: "Jira issue to GitLab MR" },
{ name: "Jira issue to Bitbucket PR" },
]}
onSelect={vi.fn()}
/>,
@@ -213,8 +213,14 @@ describe("recommended automations", () => {
"github-repo-monitor",
"slack-standup-digest",
"linear-triage-assistant",
"linear-issue-to-github-pr",
"linear-issue-to-gitlab-mr",
"linear-issue-to-bitbucket-pr",
"jira-issue-to-pr",
"qa-changes",
"jira-issue-to-gitlab-mr",
"research-brief-writer",
"jira-issue-to-bitbucket-pr",
"upstream-fork-sync",
"incident-retrospective-drafter",
]);
@@ -240,7 +246,7 @@ describe("recommended automations", () => {
expect(betaHeading).toHaveTextContent(
I18nKey.RECOMMENDED_AUTOMATIONS$BETA_LABEL,
);
expect(within(betaHeading).getByText("7")).toBeInTheDocument();
expect(within(betaHeading).getByText("13")).toBeInTheDocument();
const betaSection = screen.getByTestId(
"recommended-automations-beta-section",
+36 -5
View File
@@ -468,20 +468,22 @@ describe("buildAgentServerCommand", () => {
// Defaults to the released PyPI version with all SDK packages pinned to same version
expect(cmd.args).toEqual([
"--from",
"openhands-agent-server==1.42.1",
"openhands-agent-server==1.44.0",
"--with",
"openhands-sdk==1.42.1",
"openhands-sdk==1.44.0",
"--with",
"openhands-tools==1.42.1",
"openhands-tools==1.44.0",
"--with",
"openhands-workspace==1.42.1",
"openhands-workspace==1.44.0",
"--with",
"agent-client-protocol<0.11",
"--with",
"posthog>=6,<7",
"agent-server",
"--import-modules",
"canvas_ui_tool",
]);
expect(cmd.source).toBe("PyPI (1.42.1, default)");
expect(cmd.source).toBe("PyPI (1.44.0, default)");
});
it("uses specific PyPI version when OH_AGENT_SERVER_VERSION is set with all packages pinned", () => {
@@ -504,6 +506,8 @@ describe("buildAgentServerCommand", () => {
"--with",
"posthog>=6,<7",
"agent-server",
"--import-modules",
"canvas_ui_tool",
]);
expect(cmd.source).toBe("PyPI (1.18.0)");
});
@@ -527,6 +531,8 @@ describe("buildAgentServerCommand", () => {
"--with",
"posthog>=6,<7",
"agent-server",
"--import-modules",
"canvas_ui_tool",
]);
expect(cmd.source).toBe("git (feature-branch)");
});
@@ -548,6 +554,8 @@ describe("buildAgentServerCommand", () => {
"--with",
"posthog>=6,<7",
"agent-server",
"--import-modules",
"canvas_ui_tool",
]);
expect(cmd.source).toBe("git (abc1234)");
});
@@ -584,6 +592,8 @@ describe("buildAgentServerCommand", () => {
"--with",
"posthog>=6,<7",
"agent-server",
"--import-modules",
"canvas_ui_tool",
]);
expect(cmd.source).toBe(`local (${sdk})`);
});
@@ -604,6 +614,27 @@ describe("buildAgentServerCommand", () => {
expect(cmd.args).not.toContain("openhands-agent-server==1.18.0");
});
it("passes --import-modules to the agent-server, after the executable, in every source mode", () => {
// The flag must sit after "agent-server" so uvx hands it to the server
// instead of parsing it itself. tools/canvas_ui_tool.py documents why the
// module has to be imported before any conversation is created.
const variants = [
{},
{ OH_AGENT_SERVER_VERSION: "1.18.0" },
{ OH_AGENT_SERVER_GIT_REF: "feature-branch" },
{ OH_AGENT_SERVER_LOCAL_PATH: "/abs/path/to/software-agent-sdk" },
];
for (const env of variants) {
const { args } = buildAgentServerCommand(env);
const executable = args.indexOf("agent-server");
expect(executable).toBeGreaterThan(-1);
expect(args.slice(executable + 1)).toEqual([
"--import-modules",
"canvas_ui_tool",
]);
}
});
it("rejects relative OH_AGENT_SERVER_LOCAL_PATH", () => {
expect(() =>
buildAgentServerCommand({
@@ -70,14 +70,21 @@ describe("recommended automation rail", () => {
expect(conversationIds).toEqual([
"slack-standup-digest",
"linear-triage-assistant",
"linear-issue-to-github-pr",
"linear-issue-to-gitlab-mr",
"linear-issue-to-bitbucket-pr",
"jira-issue-to-pr",
"jira-issue-to-gitlab-mr",
"research-brief-writer",
"jira-issue-to-bitbucket-pr",
]);
expect(conversationIds).not.toContain("upstream-fork-sync");
expect(conversationIds).not.toContain("incident-retrospective-drafter");
// Beta as of extensions 0.18.0, and set up by a host form rather than a
// conversation, so it is in neither group.
expect(conversationIds).not.toContain("github-repo-monitor");
// Same for `qa-changes` (extensions 0.19.0): host form, so neither group.
expect(conversationIds).not.toContain("qa-changes");
expect(isConversationLaunchAutomation(slackStandup)).toBe(true);
expect(isConversationLaunchAutomation(upstreamFork)).toBe(false);
expect(SETUP_REGISTRY.findById(upstreamFork.id)).not.toBeNull();
@@ -94,6 +101,11 @@ describe("recommended automation rail", () => {
{ name: "Linear issue triage assistant" },
{ name: "Jira issue to GitHub PR" },
{ name: "Research brief writer" },
{ name: "Linear issue to GitHub PR" },
{ name: "Linear issue to GitLab MR" },
{ name: "Linear issue to Bitbucket PR" },
{ name: "Jira issue to GitLab MR" },
{ name: "Jira issue to Bitbucket PR" },
]);
expect(flattenRecommendedRailGroups(groups)).toEqual([]);
+2 -2
View File
@@ -1,9 +1,9 @@
{
"_comment": "Single source of truth for version pins, ports, paths, and defaults shared across the npm and Docker install paths. Read by scripts/dev-safe.mjs, scripts/dev-with-automation.mjs, docker/entrypoint.sh (via generated defaults.env), and .github/workflows/docker.yml.",
"versions": {
"agentServer": "1.42.1",
"agentServer": "1.44.0",
"agentCanvas": "1.15.0",
"automation": "1.8.0"
"automation": "1.9.0"
},
"compatibility": {
"minimumAgentServer": "1.28.0"
+8 -2
View File
@@ -268,7 +268,11 @@ export AUTOMATION_AGENT_SERVER_URL="${AUTOMATION_AGENT_SERVER_URL:-http://127.0.
# Keep the legacy canvas_ui_tool module importable when the agent-server restores
# conversations whose persisted metadata still references its module qualname.
# It is also imported at startup below (--import-modules) so its builtin
# FinishTool registration lets automation runs resolve the tool on their
# remote conversations (see the note at the bottom of tools/canvas_ui_tool.py).
export OH_EXTRA_PYTHON_PATH="${OH_EXTRA_PYTHON_PATH:-/opt/agent-canvas/tools}"
AGENT_SERVER_IMPORT_MODULES="canvas_ui_tool"
# Track child PIDs so we can clean up on exit.
PIDS=()
@@ -288,10 +292,12 @@ log "Starting agent-server on port $AGENT_SERVER_PORT..."
if command -v openhands-agent-server >/dev/null 2>&1; then
# Binary build (production image)
openhands-agent-server --port "$AGENT_SERVER_PORT" &
openhands-agent-server --port "$AGENT_SERVER_PORT" \
--import-modules "$AGENT_SERVER_IMPORT_MODULES" &
elif [ -x /agent-server/.venv/bin/python ]; then
# Source build (development image)
/agent-server/.venv/bin/python -m openhands.agent_server --port "$AGENT_SERVER_PORT" &
/agent-server/.venv/bin/python -m openhands.agent_server --port "$AGENT_SERVER_PORT" \
--import-modules "$AGENT_SERVER_IMPORT_MODULES" &
else
log_error "Cannot find agent-server binary or source venv."
exit 1
+8 -8
View File
@@ -13,8 +13,8 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.25",
"@monaco-editor/react": "4.7.0",
"@openhands/extensions": "0.18.0",
"@openhands/typescript-client": "1.38.1",
"@openhands/extensions": "0.19.0",
"@openhands/typescript-client": "1.39.0",
"@react-router/node": "7.18.2",
"@react-router/serve": "7.18.2",
"@tailwindcss/vite": "4.3.3",
@@ -4164,9 +4164,9 @@
"license": "MIT"
},
"node_modules/@openhands/extensions": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.18.0.tgz",
"integrity": "sha512-0cbVueh8wk5OLq+kbEgI5k1sFUaNgahmxXwvOR5+4fpxwc2YnVivdyDzxjZ+SiagUmYi/EgADFVa/5X38e9BzQ==",
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.19.0.tgz",
"integrity": "sha512-nT0jTxvVUmiCcKjg2lLYAUUAYFFFbMivZuCjj8qxOIXs6yePVANEpt8HkLe5ViCEweRsbIokfDwcnjQDzVhQ6w==",
"license": "MIT",
"engines": {
"node": ">=18.20.0"
@@ -4178,9 +4178,9 @@
}
},
"node_modules/@openhands/typescript-client": {
"version": "1.38.1",
"resolved": "https://registry.npmjs.org/@openhands/typescript-client/-/typescript-client-1.38.1.tgz",
"integrity": "sha512-KYpcmgL85uKZ0pvEQAxuwfj0M/8Qq+c6Q2bhaPxpwGayXkDeVWoUxIxomly4vdAqhYseK2pUJiHVoCc81gcf7Q==",
"version": "1.39.0",
"resolved": "https://registry.npmjs.org/@openhands/typescript-client/-/typescript-client-1.39.0.tgz",
"integrity": "sha512-AXRR9bXYYsT87NvkJCB2sLBxAjoGwt6MIQED4g5EufpXaUvY21BDyuDjNpsVtGl6WFnjbvTK1kFytkbxAe0rvw==",
"license": "MIT",
"dependencies": {
"@openrouter/sdk": "^1.2.11",
+2 -2
View File
@@ -23,8 +23,8 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.25",
"@monaco-editor/react": "4.7.0",
"@openhands/extensions": "0.18.0",
"@openhands/typescript-client": "1.38.1",
"@openhands/extensions": "0.19.0",
"@openhands/typescript-client": "1.39.0",
"@react-router/node": "7.18.2",
"@react-router/serve": "7.18.2",
"@tailwindcss/vite": "4.3.3",
+6 -6
View File
@@ -19,7 +19,7 @@
*
* Usage:
* node scripts/check-sdk-version-sync.mjs
* EXPECTED_SDK_VERSION=1.42.1 node scripts/check-sdk-version-sync.mjs
* EXPECTED_SDK_VERSION=1.44.0 node scripts/check-sdk-version-sync.mjs
* node scripts/check-sdk-version-sync.mjs --check-pypi
*
* Environment variables:
@@ -78,7 +78,7 @@ Triggering from other repos:
-H "Authorization: token \$GITHUB_TOKEN" \\
-H "Accept: application/vnd.github.v3+json" \\
https://api.github.com/repos/OpenHands/OpenHands/dispatches \\
-d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.42.1"}}'
-d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.44.0"}}'
`);
process.exit(0);
}
@@ -260,9 +260,9 @@ async function fetchPyPIDependencies(packageName, version) {
* Parse PyPI requires_dist array and extract SDK package versions
*
* PyPI returns dependencies in PEP 508 format like:
* "openhands-sdk>=1.42.1,<2.0.0"
* "openhands-tools==1.42.1"
* "openhands-workspace (>=1.42.1)"
* "openhands-sdk>=1.44.0,<2.0.0"
* "openhands-tools==1.44.0"
* "openhands-workspace (>=1.44.0)"
*/
function parseSdkVersionsFromRequiresDist(requiresDist) {
const versions = {};
@@ -276,7 +276,7 @@ function parseSdkVersionsFromRequiresDist(requiresDist) {
}
// Extract the version number - look for patterns like:
// ">=1.42.1", "==1.42.1", "(>=1.42.1)", "~=1.42.1"
// ">=1.44.0", "==1.44.0", "(>=1.44.0)", "~=1.44.0"
// After the package name and before any comma or closing paren
const versionPattern = /[><=~!]+\s*([0-9]+(?:\.[0-9]+)*)/;
const match = dep.match(versionPattern);
+15 -1
View File
@@ -401,6 +401,16 @@ export function validateFrontendDependencies(
}
}
/**
* Modules the agent-server imports at startup (`--import-modules`). They are
* resolved from `tools/`, which `buildAgentServerEnv` exposes through
* OH_EXTRA_PYTHON_PATH. Importing `canvas_ui_tool` eagerly registers the SDK's
* builtin FinishTool so automation presets (openhands-automation >= 1.9.0) can
* resolve it on the remote conversations they dispatch — see the note at the
* bottom of tools/canvas_ui_tool.py.
*/
export const AGENT_SERVER_IMPORT_MODULES = "canvas_ui_tool";
/**
* Build the uvx command and arguments for running agent-server.
*
@@ -411,7 +421,7 @@ export function validateFrontendDependencies(
* edits are picked up without a manual reinstall. The agent-server itself
* is rebuilt from local source on each invocation (--reinstall).
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name
* - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.42.1")
* - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.44.0")
*
* If none are set, defaults to the released version specified by
* DEFAULT_AGENT_SERVER_VERSION. Set OH_AGENT_SERVER_GIT_REF to use a
@@ -516,6 +526,10 @@ export function buildAgentServerCommand(env = process.env) {
source = `PyPI (${DEFAULT_AGENT_SERVER_VERSION}, default)`;
}
// Everything after the executable name is an agent-server CLI argument.
// Import the registration module before any conversation is created.
uvxArgs.push("--import-modules", AGENT_SERVER_IMPORT_MODULES);
return {
command: "uvx",
args: uvxArgs,
@@ -156,23 +156,54 @@ async function listAutomationRuns(
return resp.json();
}
interface AutomationRunRecord {
id: string;
status: string;
conversation_id: string | null;
error_detail?: string | null;
}
/** Statuses a run never leaves (see RunStatus in openhands-automation). */
const TERMINAL_RUN_STATUSES = new Set([
"COMPLETED",
"FAILED",
"CANCELLED",
"SKIPPED",
]);
/**
* Poll until a run reaches the expected status or times out.
*
* Fails fast, quoting the backend's `error_detail`, once every run has ended
* in some other terminal status — polling on would only surface a timeout
* and hide why the run actually stopped.
*/
async function waitForRunStatus(
request: import("@playwright/test").APIRequestContext,
automationId: string,
expectedStatus: string,
timeoutMs = 30_000,
) {
): Promise<AutomationRunRecord> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const data = await listAutomationRuns(request, automationId);
const runs = data.runs ?? data.items ?? [];
const match = runs.find(
(r: { status: string }) => r.status === expectedStatus,
);
const runs: AutomationRunRecord[] = data.runs ?? data.items ?? [];
const match = runs.find((r) => r.status === expectedStatus);
if (match) return match;
if (
runs.length > 0 &&
runs.every((r) => TERMINAL_RUN_STATUSES.has(r.status))
) {
const summary = runs
.map(
(r) =>
`${r.id}=${r.status}${r.error_detail ? ` (${r.error_detail})` : ""}`,
)
.join(", ");
throw new Error(
`No run reached "${expectedStatus}"; every run already ended: ${summary}`,
);
}
await new Promise((r) => setTimeout(r, 1_000));
}
throw new Error(
@@ -33,6 +33,11 @@ from openhands.sdk.testing import TestLLM, TestLLMExhaustedError
BASH_TOKEN = "MOCK_LLM_E2E_BASH_OK"
REPLY_TOKEN = "MOCK_LLM_E2E_REPLY_OK"
# The user turn the agent-server sends when it pre-flights a saved LLM profile
# (POST /api/profiles/{name}/validate, agent-server >= 1.43). Matched by
# _is_preflight_ping() so the check never touches the scripted trajectory.
PREFLIGHT_PING_TEXT = "ping"
# SDK exception → (HTTP status, OpenAI error type)
ERROR_MAP: dict[type, tuple[int, str]] = {
LLMAuthenticationError: (401, "invalid_api_key"),
@@ -162,6 +167,23 @@ class MockLLMHandler(BaseHTTPRequestHandler):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
# ── Profile pre-flight ping ──
# Saving a profile against agent-server >= 1.43 fires a 1-token "ping"
# completion through the submitted config, and the canvas waits at
# most 30 s for the verdict. Answer it here instead of feeding it to
# TestLLM: it would otherwise consume a scripted turn, and once the
# trajectory is exhausted the 500 below makes the SDK retry with
# backoff until the canvas gives up — leaving the profile editor stuck
# on "Validating...". It stays out of the request history, which tests
# read for the conversation's own completions.
if _is_preflight_ping(body):
raw = _preflight_pong(body.get("model"))
if body.get("stream"):
self._send_streaming(raw)
else:
self._send_json(200, raw)
return
# Append to request history for test verification.
# Tests can GET /admin/requests to confirm image content was included.
with self._lock:
@@ -338,6 +360,53 @@ class MockLLMHandler(BaseHTTPRequestHandler):
print(f"[mock-llm] {args[0]}", file=sys.stderr, flush=True)
def _is_preflight_ping(body: dict) -> bool:
"""Match the agent-server's profile pre-flight check.
It is a single user message saying "ping" with ``max_tokens=1`` (see
``profiles_router.validate_profile`` in openhands-agent-server). The text
arrives either as a plain string or as OpenAI content parts.
"""
if body.get("max_tokens") != 1:
return False
messages = body.get("messages")
if not isinstance(messages, list) or len(messages) != 1:
return False
message = messages[0]
if not isinstance(message, dict) or message.get("role") != "user":
return False
content = message.get("content")
if isinstance(content, list):
content = "".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return isinstance(content, str) and content.strip() == PREFLIGHT_PING_TEXT
def _preflight_pong(model: str | None) -> dict:
"""Minimal OpenAI-style completion for the pre-flight ping.
Uses the raw shape ``_send_streaming`` also understands, so the reply works
whether or not the caller asked for streaming.
"""
return {
"id": "chatcmpl-mock-preflight",
"object": "chat.completion",
"created": int(time.time()),
"model": model or "mock-preflight",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "pong"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
def _parse_trajectory_turns(raw_turns: list[dict]) -> list[Message | Exception]:
"""Convert JSON turn descriptors into Message objects.
@@ -410,6 +410,21 @@ test.describe("OpenHands provider hidden base_url preservation", () => {
// Advanced view. The value becomes hidden after switching to Basic, but it
// is still part of the profile unless the model changes. ──
await routeSessionApiKey(page);
// agent-server >= 1.43 pre-flights every profile save with a 1-token
// completion through the submitted config. CUSTOM_BASE_URL is a
// placeholder host by design — this test is about the value surviving a
// Basic-view re-save, not about reaching it — so answer the check from
// the browser instead of letting the SDK retry an unreachable host past
// the canvas's 30 s validation budget. Registered after
// routeSessionApiKey(): Playwright routes are LIFO, and that handler
// `continue()`s every backend request it sees first.
await page.route("**/api/profiles/*/validate", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ valid: true, error: null }),
}),
);
await ensureMockLLMAgentProfile(page.request);
await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
+19
View File
@@ -13,6 +13,10 @@ The server-side executor is a no-op that returns an acknowledgment. The actual
UI effect happens client-side: the frontend watches the WebSocket stream for
legacy ``canvas_ui`` and current ``canvas_ui_control`` ActionEvents and dispatches
the command.
The launchers also import this module at agent-server startup
(``--import-modules canvas_ui_tool``) so the builtin ``FinishTool`` registration
at the bottom runs before any conversation is created.
"""
from collections.abc import Sequence
@@ -22,8 +26,10 @@ from pydantic import Field
from openhands.sdk import Action, Observation, ToolDefinition
from openhands.sdk.tool import (
FinishTool,
ToolAnnotations,
ToolExecutor,
list_registered_tools,
register_tool,
)
@@ -137,3 +143,16 @@ class CanvasUITool(ToolDefinition[CanvasUIAction, CanvasUIObservation]):
# restoring their agent and events. Keep the registration until those records
# have a server-side migration path.
register_tool("canvas_ui", CanvasUITool)
# openhands-automation >= 1.9.0 preset entrypoints build their agent with
# get_default_agent(finish_tool_response_schema=TaskOutcome). That registers the
# SDK's builtin FinishTool only inside the entrypoint's own process and
# advertises it to the agent-server as `openhands.sdk.tool.builtins.finish` — a
# module that does not self-register — so the remote conversation every Agent
# Canvas automation run dispatches fails with "ToolDefinition 'FinishTool' is
# not registered". Registering the plain builtin here is enough: resolve_tool()
# strips `response_schema` before FinishTool.create() and re-applies it. Drop
# this once the SDK registers its builtins for remote conversations.
if FinishTool.__name__ not in list_registered_tools():
register_tool(FinishTool.__name__, FinishTool)