mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
fix: route runtime services through server_info (#16090)
Co-authored-by: neubig <398875+neubig@users.noreply.github.com> Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: neubig <neubig@users.noreply.github.com>
This commit is contained in:
co-authored by
neubig
openhands
neubig
parent
1708efc446
commit
e0b115757b
@@ -58,20 +58,21 @@ One Canvas-owned PostHog client owns telemetry and app analytics.
|
||||
|
||||
## Runtime Services in Dev Stacks
|
||||
|
||||
- When the agent-canvas dev launchers (`npm run dev` / `dev:minimal` / the published `agent-canvas` binary) start a stack, they set a `VITE_RUNTIME_SERVICES_INFO` env var on the frontend describing which services are running and how the agent should reach them. The frontend forwards this verbatim as `AgentContext.system_message_suffix` on every `POST /api/conversations`, so conversations land with a `<RUNTIME_SERVICES>` block appended to the system prompt.
|
||||
- When the agent-canvas dev launchers (`npm run dev` / `dev:static` / the published `agent-canvas` binary) start a stack with ingress/static-server, the backend-facing server appends runtime service metadata to `/server_info` as the optional `runtime_services` field. The frontend reads that backend-provided value when creating conversations and forwards it as `AgentContext.system_message_suffix` on `POST /api/conversations`, so conversations land with a `<RUNTIME_SERVICES>` block appended to the system prompt.
|
||||
- The block lists URLs **from the agent's point of view**:
|
||||
- The Agent Server is always reachable as `http://localhost:<port>` from inside the sandbox — but that is _you_, not the automation backend.
|
||||
- Host-side services (ingress, Vite, automation) are reachable as `http://localhost:<port>`.
|
||||
- Agents should treat the `<RUNTIME_SERVICES>` block as authoritative: don't hardcode `localhost:8000` for "the automation server", and don't probe random ports trying to discover services. If the block says automation is not running, skip `/api/automation` calls; otherwise use the listed `url_from_agent` + `api_prefix` (default `/api/automation`) and the `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY` header.
|
||||
- The launcher → frontend → suffix plumbing is:
|
||||
- The launcher → backend → frontend → suffix plumbing is:
|
||||
- `scripts/runtime-services-info.mjs::buildRuntimeServicesInfo()` — dependency-free module that constructs the info object; also runs as a CLI for the Docker entrypoint. Re-exported by `scripts/dev-safe.mjs` for backward compat.
|
||||
- `scripts/dev-with-automation.mjs::buildAutomationRuntimeServicesInfo()` — wraps it with automation details; called from Vite spawn (`startVite`), static frontend spawn (`startStaticFrontend` → `--runtime-services-info` flag), and the static build (`static-build.mjs`).
|
||||
- `src/api/agent-server-adapter.ts::buildRuntimeServicesSystemSuffix()` reads `VITE_RUNTIME_SERVICES_INFO` (Vite dev) or `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__` (static builds, injected by `static-server.mjs`) and renders the `<RUNTIME_SERVICES>` markdown block; `buildAgentContext()` attaches it to `agent_context.system_message_suffix` when present.
|
||||
- `scripts/dev-with-automation.mjs::buildAutomationRuntimeServicesInfo()` — wraps it with automation details. `dev-with-automation`, `dev-static`, and the published binary pass the JSON to `scripts/ingress.mjs` or `scripts/static-server.mjs` via `--runtime-services-info`.
|
||||
- `scripts/ingress.mjs` and `scripts/static-server.mjs` proxy the real agent-server `/server_info` response and append `runtime_services` when configured. This keeps version/tool compatibility fields authoritative from the SDK while letting the Agent Canvas stack advertise automation/frontend/ingress topology.
|
||||
- `src/api/agent-server-adapter.ts::fetchBackendRuntimeServicesInfo()` reads `runtime_services` from cached or freshly fetched `/server_info`; `buildRuntimeServicesSystemSuffix()` renders the `<RUNTIME_SERVICES>` markdown block; `buildAgentContext()` attaches it to `agent_context.system_message_suffix` when present.
|
||||
- E2E coverage: the mock-LLM automation test (`tests/e2e/mock-llm/automations/mock-llm-automation.spec.ts`) verifies the `<RUNTIME_SERVICES>` block reaches the LLM via `getMockLLMRequests()` and checks for Agent Server, Automation backend, and `/api/automation` entries.
|
||||
|
||||
### `VITE_RUNTIME_SERVICES_INFO` shape
|
||||
### `/server_info.runtime_services` shape
|
||||
|
||||
The env var is a JSON string of:
|
||||
The `runtime_services` value is a JSON object of:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
ACP_SERVER_TAG_KEY,
|
||||
buildRuntimeServicesSystemSuffix,
|
||||
buildStartConversationRequest,
|
||||
fetchBackendRuntimeServicesInfo,
|
||||
getDefaultConversationTitle,
|
||||
parseRuntimeServicesInfo,
|
||||
toAppConversation,
|
||||
type DirectConversationInfo,
|
||||
} from "#/api/agent-server-adapter";
|
||||
@@ -24,6 +26,8 @@ const {
|
||||
mockGetAgentServerWorkingDir,
|
||||
mockIsAgentServerToolAvailable,
|
||||
mockGetEffectiveLocalBackend,
|
||||
mockGetCachedAgentServerInfo,
|
||||
mockGetServerInfo,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"),
|
||||
mockIsAgentServerToolAvailable: vi.fn((_toolName: string) => true),
|
||||
@@ -34,6 +38,16 @@ const {
|
||||
apiKey: "session-key",
|
||||
kind: "local" as const,
|
||||
})),
|
||||
mockGetCachedAgentServerInfo: vi.fn<() => unknown>(() => null),
|
||||
mockGetServerInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@openhands/typescript-client/clients", () => ({
|
||||
ServerClient: vi.fn(function ServerClientMock() {
|
||||
return {
|
||||
getServerInfo: mockGetServerInfo,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/api/agent-server-config", () => ({
|
||||
@@ -46,6 +60,7 @@ vi.mock("#/api/agent-server-config", () => ({
|
||||
|
||||
vi.mock("#/api/agent-server-compatibility", () => ({
|
||||
isAgentServerToolAvailable: mockIsAgentServerToolAvailable,
|
||||
getCachedAgentServerInfo: mockGetCachedAgentServerInfo,
|
||||
}));
|
||||
|
||||
vi.mock("#/api/backend-registry/active-store", () => ({
|
||||
@@ -54,6 +69,8 @@ vi.mock("#/api/backend-registry/active-store", () => ({
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsAgentServerToolAvailable.mockReturnValue(true);
|
||||
mockGetCachedAgentServerInfo.mockReturnValue(null);
|
||||
mockGetServerInfo.mockReset();
|
||||
mockGetEffectiveLocalBackend.mockReturnValue({
|
||||
id: "default-local",
|
||||
name: "Local backend",
|
||||
@@ -691,6 +708,24 @@ describe("buildStartConversationRequest", () => {
|
||||
expect(payload.tool_module_qualnames).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits canvas_ui and its module qualname when the backend does not advertise canvas_ui", () => {
|
||||
mockIsAgentServerToolAvailable.mockImplementation(
|
||||
(toolName: string) => toolName !== "canvas_ui",
|
||||
);
|
||||
|
||||
const payload = buildStartConversationRequest({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
}) as {
|
||||
agent_settings: { tools: Array<{ name: string }> };
|
||||
tool_module_qualnames?: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(
|
||||
payload.agent_settings.tools.map((tool) => tool.name),
|
||||
).not.toContain("canvas_ui");
|
||||
expect(payload.tool_module_qualnames).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits the client tool for an inline ACP agent", () => {
|
||||
const payload = buildStartConversationRequest({
|
||||
settings: {
|
||||
@@ -1086,49 +1121,50 @@ describe("toAppConversation", () => {
|
||||
});
|
||||
|
||||
describe("buildRuntimeServicesSystemSuffix", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
delete (window as unknown as Record<string, unknown>)
|
||||
.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__;
|
||||
});
|
||||
|
||||
it("returns undefined when VITE_RUNTIME_SERVICES_INFO is unset", () => {
|
||||
it("returns undefined when runtime services info is absent", () => {
|
||||
expect(buildRuntimeServicesSystemSuffix()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the env var is malformed JSON", () => {
|
||||
vi.stubEnv("VITE_RUNTIME_SERVICES_INFO", "{not valid json");
|
||||
expect(buildRuntimeServicesSystemSuffix()).toBeUndefined();
|
||||
it("parses runtime services JSON strings", () => {
|
||||
expect(
|
||||
parseRuntimeServicesInfo(
|
||||
JSON.stringify({
|
||||
mode: "dev:automation",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
},
|
||||
}),
|
||||
)?.mode,
|
||||
).toBe("dev:automation");
|
||||
});
|
||||
|
||||
it("returns null when runtime services JSON is malformed", () => {
|
||||
expect(parseRuntimeServicesInfo("{not valid json")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns undefined when the JSON has no services", () => {
|
||||
vi.stubEnv("VITE_RUNTIME_SERVICES_INFO", JSON.stringify({ mode: "x" }));
|
||||
expect(buildRuntimeServicesSystemSuffix()).toBeUndefined();
|
||||
expect(buildRuntimeServicesSystemSuffix({ mode: "x" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders a <RUNTIME_SERVICES> block when an automation entry is present", () => {
|
||||
vi.stubEnv(
|
||||
"VITE_RUNTIME_SERVICES_INFO",
|
||||
JSON.stringify({
|
||||
mode: "dev:automation",
|
||||
agent_host_alias: "localhost",
|
||||
services: {
|
||||
agent_server: {
|
||||
description: "self",
|
||||
url_from_agent: "http://localhost:18000",
|
||||
},
|
||||
automation: {
|
||||
description: "automations",
|
||||
url_from_agent: "http://localhost:18001",
|
||||
api_prefix: "/api/automation",
|
||||
docs_url: "http://localhost:18001/api/automation/docs",
|
||||
openapi_url: "http://localhost:18001/api/automation/openapi.json",
|
||||
auth_env_var: "OPENHANDS_AUTOMATION_API_KEY",
|
||||
},
|
||||
const suffix = buildRuntimeServicesSystemSuffix({
|
||||
mode: "dev:automation",
|
||||
agent_host_alias: "localhost",
|
||||
services: {
|
||||
agent_server: {
|
||||
description: "self",
|
||||
url_from_agent: "http://localhost:18000",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const suffix = buildRuntimeServicesSystemSuffix();
|
||||
automation: {
|
||||
description: "automations",
|
||||
url_from_agent: "http://localhost:18001",
|
||||
api_prefix: "/api/automation",
|
||||
docs_url: "http://localhost:18001/api/automation/docs",
|
||||
openapi_url: "http://localhost:18001/api/automation/openapi.json",
|
||||
auth_env_var: "OPENHANDS_AUTOMATION_API_KEY",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(suffix).toBeDefined();
|
||||
expect(suffix).toContain("<RUNTIME_SERVICES>");
|
||||
expect(suffix).toContain("dev:automation");
|
||||
@@ -1151,16 +1187,12 @@ describe("buildRuntimeServicesSystemSuffix", () => {
|
||||
it("uses the configured agent-server URL in the don't-guess line (not a hardcoded :8000)", () => {
|
||||
// dev:safe runs the agent-server on :18000, not :8000. Make sure the
|
||||
// rendered block doesn't lie to the agent about its own URL.
|
||||
vi.stubEnv(
|
||||
"VITE_RUNTIME_SERVICES_INFO",
|
||||
JSON.stringify({
|
||||
mode: "dev:safe",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const suffix = buildRuntimeServicesSystemSuffix();
|
||||
const suffix = buildRuntimeServicesSystemSuffix({
|
||||
mode: "dev:safe",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
},
|
||||
});
|
||||
expect(suffix).toBeDefined();
|
||||
expect(suffix).toContain(
|
||||
"In particular, http://localhost:18000 inside your sandbox is the Agent Server",
|
||||
@@ -1171,21 +1203,17 @@ describe("buildRuntimeServicesSystemSuffix", () => {
|
||||
});
|
||||
|
||||
it("renders the frontend entry with the new key", () => {
|
||||
vi.stubEnv(
|
||||
"VITE_RUNTIME_SERVICES_INFO",
|
||||
JSON.stringify({
|
||||
mode: "dev:static",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
frontend: {
|
||||
kind: "static",
|
||||
description: "Static-file server hosting the agent-canvas build.",
|
||||
url_from_agent: "http://localhost:3001",
|
||||
},
|
||||
const suffix = buildRuntimeServicesSystemSuffix({
|
||||
mode: "dev:static",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
frontend: {
|
||||
kind: "static",
|
||||
description: "Static-file server hosting the agent-canvas build.",
|
||||
url_from_agent: "http://localhost:3001",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const suffix = buildRuntimeServicesSystemSuffix();
|
||||
},
|
||||
});
|
||||
expect(suffix).toContain("* Frontend: http://localhost:3001");
|
||||
expect(suffix).toContain("Static-file server");
|
||||
// Should NOT mislabel a static-build frontend as "Vite frontend".
|
||||
@@ -1193,78 +1221,60 @@ describe("buildRuntimeServicesSystemSuffix", () => {
|
||||
});
|
||||
|
||||
it("explicitly mentions when automation is absent", () => {
|
||||
vi.stubEnv(
|
||||
"VITE_RUNTIME_SERVICES_INFO",
|
||||
JSON.stringify({
|
||||
mode: "dev:safe",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const suffix = buildRuntimeServicesSystemSuffix();
|
||||
const suffix = buildRuntimeServicesSystemSuffix({
|
||||
mode: "dev:safe",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
},
|
||||
});
|
||||
expect(suffix).toBeDefined();
|
||||
expect(suffix).toContain("Automation backend: not running");
|
||||
});
|
||||
|
||||
it("falls back to window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__ when the env var is unset (static builds)", () => {
|
||||
// Static builds (Docker image / published binary) have no
|
||||
// VITE_RUNTIME_SERVICES_INFO baked in; scripts/static-server.mjs injects
|
||||
// the JSON onto window at serve time instead.
|
||||
(
|
||||
window as unknown as Record<string, unknown>
|
||||
).__AGENT_CANVAS_RUNTIME_SERVICES_INFO__ = JSON.stringify({
|
||||
mode: "docker",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://127.0.0.1:18000" },
|
||||
automation: {
|
||||
url_from_agent: "http://127.0.0.1:8000",
|
||||
api_prefix: "/api/automation",
|
||||
auth_env_var: "OPENHANDS_AUTOMATION_API_KEY",
|
||||
it("fetches runtime services from cached server_info when available", async () => {
|
||||
mockGetCachedAgentServerInfo.mockReturnValue({
|
||||
version: "1.28.0",
|
||||
runtime_services: {
|
||||
mode: "docker",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://127.0.0.1:18000" },
|
||||
automation: {
|
||||
url_from_agent: "http://127.0.0.1:8000",
|
||||
api_prefix: "/api/automation",
|
||||
auth_env_var: "OPENHANDS_AUTOMATION_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const suffix = buildRuntimeServicesSystemSuffix();
|
||||
expect(suffix).toBeDefined();
|
||||
expect(suffix).toContain("<RUNTIME_SERVICES>");
|
||||
expect(suffix).toContain("docker");
|
||||
expect(suffix).toContain("http://127.0.0.1:18000");
|
||||
expect(suffix).toContain("http://127.0.0.1:8000");
|
||||
expect(suffix).toContain(
|
||||
"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY",
|
||||
|
||||
const info = await fetchBackendRuntimeServicesInfo();
|
||||
|
||||
expect(info?.mode).toBe("docker");
|
||||
expect(info?.services?.automation?.url_from_agent).toBe(
|
||||
"http://127.0.0.1:8000",
|
||||
);
|
||||
expect(mockGetServerInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers VITE_RUNTIME_SERVICES_INFO over the window fallback", () => {
|
||||
vi.stubEnv(
|
||||
"VITE_RUNTIME_SERVICES_INFO",
|
||||
JSON.stringify({
|
||||
mode: "dev:env",
|
||||
it("fetches runtime services from /server_info when there is no cached probe", async () => {
|
||||
mockGetServerInfo.mockResolvedValue({
|
||||
version: "1.28.0",
|
||||
runtime_services: {
|
||||
mode: "dev:automation",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
(
|
||||
window as unknown as Record<string, unknown>
|
||||
).__AGENT_CANVAS_RUNTIME_SERVICES_INFO__ = JSON.stringify({
|
||||
mode: "docker:window",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://127.0.0.1:99999" },
|
||||
},
|
||||
});
|
||||
const suffix = buildRuntimeServicesSystemSuffix();
|
||||
expect(suffix).toContain("dev:env");
|
||||
expect(suffix).not.toContain("docker:window");
|
||||
expect(suffix).not.toContain("99999");
|
||||
|
||||
const info = await fetchBackendRuntimeServicesInfo();
|
||||
|
||||
expect(info?.mode).toBe("dev:automation");
|
||||
expect(mockGetServerInfo).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent_settings runtime services suffix", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not set system_message_suffix when no runtime info is provided", () => {
|
||||
const payload = buildStartConversationRequest({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
@@ -1282,10 +1292,11 @@ describe("agent_settings runtime services suffix", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets system_message_suffix when runtime info is provided", () => {
|
||||
vi.stubEnv(
|
||||
"VITE_RUNTIME_SERVICES_INFO",
|
||||
JSON.stringify({
|
||||
it("sets system_message_suffix when backend runtime info is provided", () => {
|
||||
const payload = buildStartConversationRequest({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
query: "hello",
|
||||
runtimeServicesInfo: {
|
||||
mode: "dev:automation",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
@@ -1293,11 +1304,7 @@ describe("agent_settings runtime services suffix", () => {
|
||||
url_from_agent: "http://localhost:18001",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const payload = buildStartConversationRequest({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
query: "hello",
|
||||
},
|
||||
}) as {
|
||||
agent_settings: { agent_context: Record<string, unknown> };
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
AgentServerUnavailableError,
|
||||
AgentServerUnknownVersionError,
|
||||
AgentServerUnsupportedVersionError,
|
||||
clearCachedAgentServerInfo,
|
||||
getCachedAgentServerInfo,
|
||||
loadAgentServerInfo,
|
||||
MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION,
|
||||
} from "#/api/agent-server-compatibility";
|
||||
@@ -49,6 +51,7 @@ const localBackend: Backend = {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
clearCachedAgentServerInfo();
|
||||
__resetActiveStoreForTests();
|
||||
getServerInfoMock.mockReset();
|
||||
vi.mocked(ServerClient).mockClear();
|
||||
@@ -59,6 +62,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
clearCachedAgentServerInfo();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
@@ -75,6 +79,22 @@ describe("loadAgentServerInfo", () => {
|
||||
expect(ServerClient).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns cached server info only for the probed backend host", async () => {
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
|
||||
await loadAgentServerInfo();
|
||||
|
||||
expect(getCachedAgentServerInfo({ host: localBackend.host })).toMatchObject(
|
||||
{
|
||||
version: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
getCachedAgentServerInfo({ host: "http://localhost:9001" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("throws AgentServerUnsupportedVersionError when the local backend is too old", async () => {
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { createServer, request, type Server } from "node:http";
|
||||
import { connect as netConnect, type AddressInfo, type Socket } from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
@@ -102,6 +102,50 @@ async function waitForPort(port: number, child?: ChildProcess) {
|
||||
throw new Error(`Timed out waiting for port ${port}`);
|
||||
}
|
||||
|
||||
async function getJson(url: string) {
|
||||
return new Promise<{ status: number; body: unknown }>((resolve, reject) => {
|
||||
const req = request(url, { method: "GET" }, (res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body: JSON.parse(body),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function getText(url: string) {
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const req = request(url, { method: "GET" }, (res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body,
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function stopChild(child?: ChildProcess) {
|
||||
if (!child || child.exitCode !== null) {
|
||||
return;
|
||||
@@ -338,6 +382,141 @@ describe("ingress proxy functionality", () => {
|
||||
await stopChild(badIngress);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 502 when backend target URL is invalid", async () => {
|
||||
const badIngressPort = await getFreePort();
|
||||
const badIngress = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
ingressScript,
|
||||
"--port",
|
||||
badIngressPort.toString(),
|
||||
"--default",
|
||||
"not-a-url",
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
let stderr = "";
|
||||
badIngress.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
await waitForPort(badIngressPort, badIngress);
|
||||
|
||||
try {
|
||||
const response = await getText(`${originForPort(badIngressPort)}/test`);
|
||||
await delay(100);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body).toContain("Bad Gateway");
|
||||
expect(response.body).toContain("Invalid URL");
|
||||
expect(badIngress.exitCode).toBeNull();
|
||||
expect(stderr).toContain("Invalid URL");
|
||||
} finally {
|
||||
await stopChild(badIngress);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds runtime_services to proxied /server_info", async () => {
|
||||
const serverInfoBackend = createServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ version: "1.28.0" }));
|
||||
});
|
||||
const serverInfoBackendPort = await listenOnLoopback(serverInfoBackend);
|
||||
const runtimeIngressPort = await getFreePort();
|
||||
const runtimeServicesInfo = JSON.stringify({
|
||||
mode: "dev:automation",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://localhost:18000" },
|
||||
automation: { url_from_agent: "http://localhost:18001" },
|
||||
},
|
||||
});
|
||||
const runtimeIngress = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
ingressScript,
|
||||
"--port",
|
||||
runtimeIngressPort.toString(),
|
||||
"--runtime-services-info",
|
||||
runtimeServicesInfo,
|
||||
"--route",
|
||||
`/server_info=${originForPort(serverInfoBackendPort)}`,
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
await waitForPort(runtimeIngressPort, runtimeIngress);
|
||||
|
||||
try {
|
||||
const response = await getJson(
|
||||
`${originForPort(runtimeIngressPort)}/server_info`,
|
||||
);
|
||||
const body = response.body as {
|
||||
version?: string;
|
||||
runtime_services?: unknown;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.version).toBe("1.28.0");
|
||||
expect(body.runtime_services).toEqual(JSON.parse(runtimeServicesInfo));
|
||||
} finally {
|
||||
await stopChild(runtimeIngress);
|
||||
await closeServer(serverInfoBackend);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 502 when intercepted /server_info target URL is invalid", async () => {
|
||||
const runtimeIngressPort = await getFreePort();
|
||||
const runtimeServicesInfo = JSON.stringify({
|
||||
mode: "dev:automation",
|
||||
services: {},
|
||||
});
|
||||
const runtimeIngress = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
ingressScript,
|
||||
"--port",
|
||||
runtimeIngressPort.toString(),
|
||||
"--runtime-services-info",
|
||||
runtimeServicesInfo,
|
||||
"--route",
|
||||
"/server_info=not-a-url",
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
let stderr = "";
|
||||
runtimeIngress.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
await waitForPort(runtimeIngressPort, runtimeIngress);
|
||||
|
||||
try {
|
||||
const response = await getText(
|
||||
`${originForPort(runtimeIngressPort)}/server_info`,
|
||||
);
|
||||
await delay(100);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body).toContain("Bad Gateway");
|
||||
expect(response.body).toContain("Invalid backend URL");
|
||||
expect(runtimeIngress.exitCode).toBeNull();
|
||||
expect(stderr).toContain("Invalid backend URL");
|
||||
} finally {
|
||||
await stopChild(runtimeIngress);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingress route matching", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Server } from "node:http";
|
||||
import { createServer, request, type Server } from "node:http";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -46,6 +46,66 @@ describe("static-server.mjs", () => {
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function startHttpServer(server: Server) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Server did not bind to a TCP port");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function getJson(url: string) {
|
||||
return new Promise<{ status: number; body: unknown }>((resolve, reject) => {
|
||||
const req = request(url, { method: "GET" }, (res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body: JSON.parse(body),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function getText(url: string) {
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const req = request(url, { method: "GET" }, (res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body,
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe("parseArgs", () => {
|
||||
it("defaults sessionApiKey to null", () => {
|
||||
const config = parseArgs([]);
|
||||
@@ -112,7 +172,7 @@ describe("static-server.mjs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime services info injection", () => {
|
||||
describe("runtime services info exposure", () => {
|
||||
async function startServerWithRuntimeInfo(
|
||||
dir: string,
|
||||
runtimeServicesInfo: string,
|
||||
@@ -132,10 +192,9 @@ describe("static-server.mjs", () => {
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
// Regression test for the Docker / published-binary path: static builds
|
||||
// have no VITE_RUNTIME_SERVICES_INFO baked in, so the agent's
|
||||
// <RUNTIME_SERVICES> block is populated from this injected window global
|
||||
// (see `parseRuntimeServicesInfo()` in src/api/agent-server-adapter.ts).
|
||||
// Legacy compatibility for older Docker / published-binary frontend
|
||||
// bundles, which read runtime services from this injected window global.
|
||||
// New bundles read /server_info.runtime_services instead.
|
||||
it("exposes the JSON on window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__", async () => {
|
||||
const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
|
||||
tempDirs.push(buildDir);
|
||||
@@ -154,8 +213,8 @@ describe("static-server.mjs", () => {
|
||||
const body = await (await fetch(`${origin}/`)).text();
|
||||
|
||||
expect(body).toContain("window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__");
|
||||
// Stored as a JSON *string* (note the escaped quotes) so the browser can
|
||||
// JSON.parse it, exactly like the VITE_RUNTIME_SERVICES_INFO env var.
|
||||
// Stored as a JSON *string* (note the escaped quotes) so older browser
|
||||
// code can JSON.parse it.
|
||||
expect(body).toContain('\\"mode\\"');
|
||||
expect(body).toContain("docker");
|
||||
});
|
||||
@@ -183,6 +242,82 @@ describe("static-server.mjs", () => {
|
||||
const body = await (await fetch(`${origin}/`)).text();
|
||||
expect(body).not.toContain("__AGENT_CANVAS_RUNTIME_SERVICES_INFO__");
|
||||
});
|
||||
|
||||
it("adds runtime_services to proxied /server_info", async () => {
|
||||
const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
|
||||
tempDirs.push(buildDir);
|
||||
writeFileSync(
|
||||
path.join(buildDir, "index.html"),
|
||||
"<html><head></head><body>app</body></html>",
|
||||
);
|
||||
|
||||
const upstreamOrigin = await startHttpServer(
|
||||
createServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ version: "1.28.0" }));
|
||||
}),
|
||||
);
|
||||
const runtimeServicesInfo = JSON.stringify({
|
||||
mode: "docker",
|
||||
services: {
|
||||
agent_server: { url_from_agent: "http://127.0.0.1:18000" },
|
||||
},
|
||||
});
|
||||
|
||||
const server = await startStaticServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
dir: buildDir,
|
||||
routes: { "/server_info": upstreamOrigin },
|
||||
runtimeServicesInfo,
|
||||
});
|
||||
servers.push(server);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("No port");
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const response = await getJson(`${origin}/server_info`);
|
||||
const body = response.body as {
|
||||
version?: string;
|
||||
runtime_services?: unknown;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.version).toBe("1.28.0");
|
||||
expect(body.runtime_services).toEqual(JSON.parse(runtimeServicesInfo));
|
||||
});
|
||||
|
||||
it("returns 502 when proxied /server_info target URL is invalid", async () => {
|
||||
const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
|
||||
tempDirs.push(buildDir);
|
||||
writeFileSync(
|
||||
path.join(buildDir, "index.html"),
|
||||
"<html><head></head><body>app</body></html>",
|
||||
);
|
||||
|
||||
const runtimeServicesInfo = JSON.stringify({
|
||||
mode: "docker",
|
||||
services: {},
|
||||
});
|
||||
const server = await startStaticServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
dir: buildDir,
|
||||
routes: { "/server_info": "not-a-url" },
|
||||
runtimeServicesInfo,
|
||||
});
|
||||
servers.push(server);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("No port");
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const response = await getText(`${origin}/server_info`);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body).toContain("Bad Gateway");
|
||||
expect(response.body).toContain("Invalid backend URL");
|
||||
expect(server.listening).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lock-to-cloud injection", () => {
|
||||
@@ -507,4 +642,28 @@ describe("static-server.mjs", () => {
|
||||
expect(response.status).not.toBe(200);
|
||||
await expect(response.text()).resolves.not.toContain("secret");
|
||||
});
|
||||
|
||||
it("returns 502 when backend target URL is invalid", async () => {
|
||||
const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
|
||||
tempDirs.push(buildDir);
|
||||
writeFileSync(path.join(buildDir, "index.html"), "<main>app</main>");
|
||||
|
||||
const server = await startStaticServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
dir: buildDir,
|
||||
routes: { "/api/invalid": "not-a-url" },
|
||||
});
|
||||
servers.push(server);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("No port");
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const response = await getText(`${origin}/api/invalid/test`);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body).toContain("Bad Gateway");
|
||||
expect(response.body).toContain("Invalid URL");
|
||||
expect(server.listening).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,12 +243,10 @@ log "Starting frontend + proxy on port $PORT..."
|
||||
# Describe the local runtime services so the frontend can populate the agent's
|
||||
# <RUNTIME_SERVICES> system-prompt block (without it the agent does not know how
|
||||
# to reach the local automation backend and falls back to the cloud API). These
|
||||
# URLs are runtime config (overridable at `docker run`), so unlike the dev
|
||||
# launchers we cannot bake VITE_RUNTIME_SERVICES_INFO into the image at build
|
||||
# time — we build the JSON here from the sandbox-facing URLs the entrypoint
|
||||
# already exports and inject it at serve time via
|
||||
# static-server.mjs --runtime-services-info. The shape comes from the same
|
||||
# builder the dev stack uses (scripts/runtime-services-info.mjs).
|
||||
# URLs are runtime config (overridable at `docker run`), so build the JSON here
|
||||
# from the sandbox-facing URLs the entrypoint already exports. static-server.mjs
|
||||
# appends it to /server_info as runtime_services and also injects the legacy
|
||||
# window global for older frontend bundles.
|
||||
RUNTIME_SERVICES_INFO="$(node /opt/agent-canvas/runtime-services-info.mjs \
|
||||
--mode docker \
|
||||
--agent-host-alias 127.0.0.1 \
|
||||
|
||||
@@ -28,7 +28,7 @@ Optional runtime services include:
|
||||
- An Automation Server for scheduled or event-triggered agent runs.
|
||||
- OpenHands Cloud APIs for hosted sandbox and organization workflows.
|
||||
|
||||
The development launchers expose runtime service information through `VITE_RUNTIME_SERVICES_INFO`. The frontend forwards that information into new conversations as an agent context suffix so agents can use the correct URLs instead of guessing ports.
|
||||
Agent Canvas stack launchers expose runtime service information through the backend `/server_info.runtime_services` field. The frontend forwards that backend-provided information into new conversations as an agent context suffix so agents can use the correct URLs instead of guessing ports.
|
||||
|
||||
## Frontend modules
|
||||
|
||||
|
||||
+15
-10
@@ -264,7 +264,9 @@ export async function assertPortsFree(portConfigs, host = "127.0.0.1") {
|
||||
const busy = results.filter(({ free }) => !free);
|
||||
if (busy.length === 0) return;
|
||||
|
||||
const lines = busy.map(({ name, port }) => ` • ${name}: port ${port}`).join("\n");
|
||||
const lines = busy
|
||||
.map(({ name, port }) => ` • ${name}: port ${port}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`Cannot start: the following ports are already in use:\n\n${lines}\n\n` +
|
||||
`Another agent-canvas instance may already be running.\n` +
|
||||
@@ -619,8 +621,7 @@ function buildConfigFromPorts(ports, cwd, env) {
|
||||
// ~/.openhands/agent-canvas/secret-key.txt. Persisting ensures dev mode
|
||||
// and Docker mode share the same encryption key when they mount the same
|
||||
// ~/.openhands directory (docker/entrypoint.sh reads/writes the same file).
|
||||
const secretKeyPath =
|
||||
env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH;
|
||||
const secretKeyPath = env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH;
|
||||
const secretKey =
|
||||
env.OH_SECRET_KEY || getOrCreatePersistedApiKey(secretKeyPath, "secret");
|
||||
// Use the user-provided LOCAL_BACKEND_API_KEY or fall back to a key
|
||||
@@ -958,10 +959,6 @@ async function main() {
|
||||
}
|
||||
|
||||
const frontendCommand = buildNpmScriptCommand("dev:frontend");
|
||||
const runtimeServicesInfo = buildRuntimeServicesInfo({
|
||||
mode: "dev:safe",
|
||||
agentServerPort: config.backendPort,
|
||||
});
|
||||
frontend = spawnProcess(frontendCommand.command, frontendCommand.args, {
|
||||
cwd: config.cwd,
|
||||
env: {
|
||||
@@ -971,9 +968,17 @@ async function main() {
|
||||
VITE_WORKING_DIR: config.workingDir,
|
||||
// Pass session API key so frontend can authenticate with agent-server
|
||||
VITE_SESSION_API_KEY: config.sessionApiKey,
|
||||
// Inform the frontend (and downstream, the agent's system prompt) about
|
||||
// which services are available in this dev stack.
|
||||
VITE_RUNTIME_SERVICES_INFO: JSON.stringify(runtimeServicesInfo),
|
||||
// dev:minimal deliberately does NOT supply runtime-services info (the
|
||||
// frontend here talks straight to the agent-server over
|
||||
// VITE_BACKEND_BASE_URL — there is no ingress or static-server in front
|
||||
// of it to append `runtime_services` to `/server_info`, and the
|
||||
// frontend's own VITE_RUNTIME_SERVICES_INFO env var is no longer read).
|
||||
// It is a bare agent-server + Vite stack with no companion services to
|
||||
// advertise, so `fetchBackendRuntimeServicesInfo()` correctly returns
|
||||
// null and conversations simply omit the <RUNTIME_SERVICES> block.
|
||||
// Stacks with automation/ingress/frontend services should use
|
||||
// `npm run dev` / `dev:static`, which pass runtime-services info through
|
||||
// ingress/static-server instead.
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
buildAgentServerAutomationEnv,
|
||||
buildAutomationCommand,
|
||||
buildAutomationTelemetryEnv,
|
||||
buildAutomationRuntimeServicesInfo,
|
||||
buildConfig,
|
||||
} from "./dev-with-automation.mjs";
|
||||
|
||||
@@ -379,6 +380,12 @@ function startStaticServer(config) {
|
||||
// is forwarded to the agent-server instead of falling back to the SPA
|
||||
// shell). Without this, /server_info on :3001 returns index.html.
|
||||
const staticServerScript = join(projectRoot, "scripts", "static-server.mjs");
|
||||
const runtimeServicesInfo = JSON.stringify(
|
||||
buildAutomationRuntimeServicesInfo({
|
||||
...config,
|
||||
frontendKind: "static",
|
||||
}),
|
||||
);
|
||||
spawnService(
|
||||
"static",
|
||||
"node",
|
||||
@@ -396,6 +403,8 @@ function startStaticServer(config) {
|
||||
...(config.sessionApiKey
|
||||
? ["--session-api-key", config.sessionApiKey]
|
||||
: []),
|
||||
"--runtime-services-info",
|
||||
runtimeServicesInfo,
|
||||
"--route",
|
||||
`/api/automation=http://localhost:${config.autoBackendPort}`,
|
||||
"--route",
|
||||
@@ -428,6 +437,12 @@ function startIngress(config) {
|
||||
logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
|
||||
|
||||
const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
|
||||
const runtimeServicesInfo = JSON.stringify(
|
||||
buildAutomationRuntimeServicesInfo({
|
||||
...config,
|
||||
frontendKind: "static",
|
||||
}),
|
||||
);
|
||||
|
||||
spawnService(
|
||||
"ingress",
|
||||
@@ -436,6 +451,8 @@ function startIngress(config) {
|
||||
ingressScript,
|
||||
"--port",
|
||||
config.ingressPort.toString(),
|
||||
"--runtime-services-info",
|
||||
runtimeServicesInfo,
|
||||
"--route",
|
||||
`/api/automation=http://localhost:${config.autoBackendPort}`,
|
||||
"--route",
|
||||
|
||||
@@ -964,6 +964,9 @@ function startIngress(config) {
|
||||
|
||||
const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
|
||||
const frontendBackend = getFrontendBackend(config);
|
||||
const runtimeServicesInfo = config.launchAgentServer
|
||||
? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
|
||||
: null;
|
||||
|
||||
spawnService(
|
||||
"ingress",
|
||||
@@ -972,6 +975,9 @@ function startIngress(config) {
|
||||
ingressScript,
|
||||
"--port",
|
||||
config.ingressPort.toString(),
|
||||
...(runtimeServicesInfo
|
||||
? ["--runtime-services-info", runtimeServicesInfo]
|
||||
: []),
|
||||
...buildRouteArgs(getLocalServiceRoutes(config)),
|
||||
...(frontendBackend ? ["--default", frontendBackend] : []),
|
||||
],
|
||||
@@ -984,8 +990,8 @@ function startIngress(config) {
|
||||
|
||||
/**
|
||||
* Build the JSON-serializable runtime services info for an automation
|
||||
* stack. Used by both the Vite dev server (dev mode) and static-build.mjs
|
||||
* (static mode) so the frontend can populate the agent's
|
||||
* stack. Backend-serving processes append this to `/server_info` so any
|
||||
* frontend connected to the backend can populate the agent's
|
||||
* `<RUNTIME_SERVICES>` system-prompt block.
|
||||
*/
|
||||
export function buildAutomationRuntimeServicesInfo(config) {
|
||||
@@ -1009,9 +1015,6 @@ function startVite(config) {
|
||||
logService("vite", `Starting on port ${config.vitePort}...`, c.magenta);
|
||||
|
||||
const frontendCommand = buildNpmScriptCommand("dev:frontend");
|
||||
const runtimeServicesInfo = config.launchAgentServer
|
||||
? buildAutomationRuntimeServicesInfo(config)
|
||||
: null;
|
||||
|
||||
const viteEnv = {
|
||||
// Full-stack mode points Vite at this launcher's ingress. Frontend-only
|
||||
@@ -1023,12 +1026,6 @@ function startVite(config) {
|
||||
viteEnv.VITE_WORKING_DIR = config.viteWorkingDir;
|
||||
}
|
||||
|
||||
if (runtimeServicesInfo) {
|
||||
// Inform the frontend (and downstream, the agent's system prompt) about
|
||||
// which services are available in this dev stack.
|
||||
viteEnv.VITE_RUNTIME_SERVICES_INFO = JSON.stringify(runtimeServicesInfo);
|
||||
}
|
||||
|
||||
// In local mode, bake the session key into the frontend so the user
|
||||
// never has to paste it. In public mode, omit the key and set
|
||||
// VITE_AUTH_REQUIRED so the frontend shows the API key entry screen
|
||||
@@ -1442,9 +1439,9 @@ function startStaticFrontend(config, staticDir) {
|
||||
logService("static", `Starting on port ${config.vitePort}...`, c.magenta);
|
||||
logService("static", `Serving from: ${staticDir}`, c.dim);
|
||||
|
||||
// Build the runtime-services info JSON so the pre-built frontend can
|
||||
// populate the agent's <RUNTIME_SERVICES> system-prompt block without
|
||||
// VITE_RUNTIME_SERVICES_INFO baked in at build time.
|
||||
// Build the runtime-services info JSON so static-server can append it to
|
||||
// /server_info. The static-server also injects the old window global for
|
||||
// compatibility with previously built frontend bundles.
|
||||
const runtimeServicesInfo = config.launchAgentServer
|
||||
? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
|
||||
: null;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* INGRESS_PORT - Port to listen on (default: 8000)
|
||||
* INGRESS_ROUTES - JSON object of path prefix -> backend URL
|
||||
* INGRESS_DEFAULT - Default backend for unmatched routes
|
||||
* INGRESS_RUNTIME_SERVICES_INFO - Runtime services JSON appended to
|
||||
* /server_info
|
||||
*
|
||||
* Route matching:
|
||||
* - Routes are matched by longest prefix first
|
||||
@@ -27,6 +29,8 @@ import {
|
||||
createProxyHandlers,
|
||||
createRouter,
|
||||
isBenignSocketError,
|
||||
isServerInfoRequest,
|
||||
proxyServerInfoRequest,
|
||||
} from "./proxy-utils.mjs";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -39,6 +43,7 @@ function parseArgs() {
|
||||
port: 8000,
|
||||
routes: {},
|
||||
defaultBackend: null,
|
||||
runtimeServicesInfo: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -57,6 +62,9 @@ function parseArgs() {
|
||||
case "--default":
|
||||
config.defaultBackend = args[++i];
|
||||
break;
|
||||
case "--runtime-services-info":
|
||||
config.runtimeServicesInfo = args[++i] || null;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
showHelp();
|
||||
@@ -80,12 +88,15 @@ OPTIONS:
|
||||
-p, --port <port> Port to listen on (default: 8000)
|
||||
-r, --route <path=url> Add a route (can be repeated)
|
||||
-d, --default <url> Default backend for unmatched routes
|
||||
--runtime-services-info Runtime services JSON for /server_info
|
||||
-h, --help Show this help
|
||||
|
||||
ENVIRONMENT VARIABLES:
|
||||
INGRESS_PORT Port to listen on
|
||||
INGRESS_ROUTES JSON object: {"path": "url", ...}
|
||||
INGRESS_DEFAULT Default backend URL
|
||||
INGRESS_RUNTIME_SERVICES_INFO
|
||||
Runtime services JSON for /server_info
|
||||
|
||||
EXAMPLES:
|
||||
# Basic setup with agent server and automation
|
||||
@@ -125,6 +136,8 @@ function buildConfig(args, env = process.env) {
|
||||
port: args.port || parseInt(env.INGRESS_PORT, 10) || 8000,
|
||||
routes,
|
||||
defaultBackend: args.defaultBackend || env.INGRESS_DEFAULT || null,
|
||||
runtimeServicesInfo:
|
||||
args.runtimeServicesInfo || env.INGRESS_RUNTIME_SERVICES_INFO || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,6 +159,15 @@ export function startIngress(config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
config.runtimeServicesInfo &&
|
||||
isServerInfoRequest(req) &&
|
||||
(req.method === "GET" || req.method === "HEAD")
|
||||
) {
|
||||
proxyServerInfoRequest(req, res, backend, config.runtimeServicesInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
proxy.proxyHttp(req, res, backend);
|
||||
});
|
||||
|
||||
|
||||
+157
-2
@@ -1,6 +1,9 @@
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { createProxyServer } from "httpxy";
|
||||
|
||||
const DEFAULT_PROXY_TIMEOUT_MS = 120_000;
|
||||
const SERVER_INFO_PATH = "/server_info";
|
||||
const BENIGN_SOCKET_ERRORS = new Set([
|
||||
"ECONNRESET",
|
||||
"EPIPE",
|
||||
@@ -35,6 +38,152 @@ export function isBenignSocketError(err) {
|
||||
return Boolean(err && BENIGN_SOCKET_ERRORS.has(err.code));
|
||||
}
|
||||
|
||||
function parseBackendUrl(backendUrl) {
|
||||
const url = new URL(backendUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("Invalid backend URL");
|
||||
}
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80),
|
||||
protocol: url.protocol,
|
||||
};
|
||||
}
|
||||
|
||||
function writeInvalidBackendUrlResponse(req, res) {
|
||||
const message = "Invalid backend URL";
|
||||
console.error(`Proxy error for ${req.url}: ${message}`);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end(`Bad Gateway: ${message}`);
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function isServerInfoRequest(req) {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
return pathname === SERVER_INFO_PATH;
|
||||
}
|
||||
|
||||
export function proxyServerInfoRequest(
|
||||
req,
|
||||
res,
|
||||
backendUrl,
|
||||
runtimeServicesInfo,
|
||||
) {
|
||||
let backend;
|
||||
try {
|
||||
backend = parseBackendUrl(backendUrl);
|
||||
} catch {
|
||||
writeInvalidBackendUrlResponse(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = backend.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
const proxyReq = request(
|
||||
{
|
||||
hostname: backend.hostname,
|
||||
port: backend.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `${backend.hostname}:${backend.port}`,
|
||||
},
|
||||
},
|
||||
(proxyRes) => {
|
||||
const chunks = [];
|
||||
|
||||
proxyRes.on("data", (chunk) => {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
});
|
||||
|
||||
proxyRes.on("error", (err) => {
|
||||
if (!isBenignSocketError(err)) {
|
||||
console.error(`Upstream response error for ${req.url}:`, err.message);
|
||||
}
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502);
|
||||
res.end(`Bad Gateway: ${err.message}`);
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
proxyRes.on("end", () => {
|
||||
const statusCode = proxyRes.statusCode ?? 502;
|
||||
const headers = { ...proxyRes.headers };
|
||||
const originalBody = Buffer.concat(chunks);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300 || req.method === "HEAD") {
|
||||
res.writeHead(statusCode, headers);
|
||||
res.end(req.method === "HEAD" ? "" : originalBody);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const serverInfo = JSON.parse(originalBody.toString("utf8"));
|
||||
const runtimeServices =
|
||||
typeof runtimeServicesInfo === "string"
|
||||
? JSON.parse(runtimeServicesInfo)
|
||||
: runtimeServicesInfo;
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({
|
||||
...serverInfo,
|
||||
runtime_services: runtimeServices,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
delete headers["content-length"];
|
||||
delete headers["transfer-encoding"];
|
||||
headers["content-type"] = "application/json; charset=utf-8";
|
||||
headers["cache-control"] = "no-store";
|
||||
res.writeHead(statusCode, headers);
|
||||
res.end(body);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Could not append runtime_services to ${SERVER_INFO_PATH}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
res.writeHead(statusCode, headers);
|
||||
res.end(originalBody);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
if (!isBenignSocketError(err)) {
|
||||
console.error(`Proxy error for ${req.url}:`, err.message);
|
||||
}
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502);
|
||||
res.end(`Bad Gateway: ${err.message}`);
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
if (!isBenignSocketError(err)) {
|
||||
console.error(`Client request error for ${req.url}:`, err.message);
|
||||
}
|
||||
proxyReq.destroy();
|
||||
});
|
||||
|
||||
res.on("error", (err) => {
|
||||
if (!isBenignSocketError(err)) {
|
||||
console.error(`Client response error for ${req.url}:`, err.message);
|
||||
}
|
||||
proxyReq.destroy();
|
||||
});
|
||||
|
||||
req.pipe(proxyReq, { end: true });
|
||||
}
|
||||
|
||||
function once(fn) {
|
||||
let called = false;
|
||||
return (...args) => {
|
||||
@@ -97,7 +246,7 @@ export function createProxyHandlers({
|
||||
res.on("finish", finish);
|
||||
res.on("error", finish);
|
||||
|
||||
proxy.web(req, res, { target }).catch((err) => {
|
||||
const handleProxyError = (err) => {
|
||||
metrics.totalErrors += 1;
|
||||
if (!isBenignSocketError(err)) {
|
||||
console.error(
|
||||
@@ -107,7 +256,13 @@ export function createProxyHandlers({
|
||||
}
|
||||
writeProxyError(res, err instanceof Error ? err.message : String(err));
|
||||
finish();
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
proxy.web(req, res, { target }).catch(handleProxyError);
|
||||
} catch (err) {
|
||||
handleProxyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function proxyWebSocket(req, socket, head, target) {
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
* Single source of truth for the `<RUNTIME_SERVICES>` block.
|
||||
*
|
||||
* Builds a structured description of the services that are reachable from
|
||||
* inside the agent's sandbox. The frontend forwards it (verbatim, as a JSON
|
||||
* string) and renders it into the system prompt via
|
||||
* `AgentContext.system_message_suffix`, so the agent sees a
|
||||
* inside the agent's sandbox. Agent Canvas backend-serving processes attach it
|
||||
* to `/server_info.runtime_services`; the frontend renders that backend value
|
||||
* into `AgentContext.system_message_suffix`, so the agent sees a
|
||||
* `<RUNTIME_SERVICES>` block listing what's available without having to probe.
|
||||
*
|
||||
* Two callers share this one definition:
|
||||
* - the dev launchers (scripts/dev-*.mjs), which know the stack as a set of
|
||||
* ports and bake the result into `VITE_RUNTIME_SERVICES_INFO` at build time;
|
||||
* ports and pass the result to ingress/static-server for `/server_info`;
|
||||
* - docker/entrypoint.sh, which runs this file as a CLI (see the bottom of
|
||||
* this module) because in a container the URLs are *runtime* config — the
|
||||
* ports and base URLs are overridable at `docker run` and therefore cannot
|
||||
* be baked into the image at build time. The JSON it prints is injected
|
||||
* into index.html at serve time by scripts/static-server.mjs.
|
||||
* be baked into the image at build time. The JSON it prints is passed to
|
||||
* scripts/static-server.mjs and exposed through `/server_info`.
|
||||
*
|
||||
* URLs are written from the *agent's* point of view (i.e. as the agent should
|
||||
* curl/fetch them from inside its sandbox), which is deliberately not the
|
||||
|
||||
@@ -3,7 +3,6 @@ import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
buildAutomationRuntimeServicesInfo,
|
||||
c,
|
||||
logError,
|
||||
logService,
|
||||
@@ -45,12 +44,6 @@ export function buildFrontend(config, args = {}) {
|
||||
// Bake the session API key — used by the frontend for both agent-server
|
||||
// and automation auth via the `X-Session-API-Key` header.
|
||||
VITE_SESSION_API_KEY: config.sessionApiKey,
|
||||
// Bake a description of the runtime services in this dev stack so the
|
||||
// frontend can populate the agent's <RUNTIME_SERVICES> system-prompt
|
||||
// block when creating a conversation.
|
||||
VITE_RUNTIME_SERVICES_INFO: JSON.stringify(
|
||||
buildAutomationRuntimeServicesInfo(config),
|
||||
),
|
||||
// Intentionally do NOT set VITE_BACKEND_BASE_URL: leaving it unset makes
|
||||
// the runtime fall back to window.location.origin, which keeps the build
|
||||
// portable across localhost, LAN hosts, and tunnels such as ngrok.
|
||||
|
||||
@@ -37,7 +37,9 @@ import sirv from "sirv";
|
||||
import {
|
||||
createProxyHandlers,
|
||||
createRouter,
|
||||
isServerInfoRequest,
|
||||
matchesPathPrefix,
|
||||
proxyServerInfoRequest,
|
||||
} from "./proxy-utils.mjs";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -558,6 +560,14 @@ export function startStaticServer(config) {
|
||||
const server = createServer((req, res) => {
|
||||
const backend = route(req.url ?? "/");
|
||||
if (backend) {
|
||||
if (
|
||||
config.runtimeServicesInfo &&
|
||||
isServerInfoRequest(req) &&
|
||||
(req.method === "GET" || req.method === "HEAD")
|
||||
) {
|
||||
proxyServerInfoRequest(req, res, backend, config.runtimeServicesInfo);
|
||||
return;
|
||||
}
|
||||
proxy.proxyHttp(req, res, backend);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ACP_SETTINGS_KEYS } from "@openhands/typescript-client";
|
||||
import { ServerClient } from "@openhands/typescript-client/clients";
|
||||
import { SKILLS_CATALOG } from "@openhands/extensions/skills";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { ExecutionStatus } from "#/types/agent-server/core";
|
||||
@@ -9,7 +10,10 @@ import {
|
||||
resolveEffectiveAcpModel,
|
||||
} from "#/constants/acp-providers";
|
||||
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
||||
import { isAgentServerToolAvailable } from "./agent-server-compatibility";
|
||||
import {
|
||||
getCachedAgentServerInfo,
|
||||
isAgentServerToolAvailable,
|
||||
} from "./agent-server-compatibility";
|
||||
import { getAgentServerWorkingDir } from "./agent-server-config";
|
||||
import { getEffectiveLocalBackend } from "./backend-registry/active-store";
|
||||
import { buildAuthHeaders } from "./backend-registry/auth";
|
||||
@@ -103,15 +107,13 @@ function browserToolsEnabled() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the runtime services info (set by the dev launchers in
|
||||
* scripts/dev-*.mjs as `VITE_RUNTIME_SERVICES_INFO`, or injected at serve time
|
||||
* by `scripts/static-server.mjs` for static builds — see
|
||||
* `getRawRuntimeServicesInfo`). All URLs are written from the agent's point of
|
||||
* Shape of the runtime services info served by Agent Canvas backends in
|
||||
* `/server_info.runtime_services`. All URLs are written from the agent's point of
|
||||
* view, not the browser's. The block is rendered into the agent's system prompt
|
||||
* via `AgentContext.system_message_suffix` so the agent knows what's
|
||||
* reachable from inside its sandbox without having to probe.
|
||||
* via `AgentContext.system_message_suffix` so the agent knows what's reachable
|
||||
* from inside its sandbox without having to probe.
|
||||
*/
|
||||
interface RuntimeServicesInfo {
|
||||
export interface RuntimeServicesInfo {
|
||||
mode?: string;
|
||||
agent_host_alias?: string;
|
||||
services?: {
|
||||
@@ -136,66 +138,72 @@ interface RuntimeServicesInfo {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the raw runtime-services JSON string, consulting two sources in order
|
||||
* (mirrors `getBakedSessionApiKey` in agent-server-config.ts):
|
||||
* 1. `VITE_RUNTIME_SERVICES_INFO` — baked into the bundle at build time by
|
||||
* the dev launchers (`npm run dev`, dev:static).
|
||||
* 2. `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__` — injected into
|
||||
* index.html at serve time by `scripts/static-server.mjs
|
||||
* --runtime-services-info <json>`. This is the path used by static builds
|
||||
* (the Docker image and the published binary), where the env var is empty
|
||||
* in the prebuilt bundle. Without it the `<RUNTIME_SERVICES>` block is
|
||||
* missing and the agent cannot reach the local automation backend.
|
||||
*/
|
||||
function getRawRuntimeServicesInfo(): string | null {
|
||||
const envRaw = import.meta.env.VITE_RUNTIME_SERVICES_INFO?.trim();
|
||||
if (envRaw) return envRaw;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const injected = (window as unknown as Record<string, unknown>)
|
||||
.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__;
|
||||
if (typeof injected === "string") {
|
||||
return injected.trim() || null;
|
||||
export function parseRuntimeServicesInfo(
|
||||
value: unknown,
|
||||
): RuntimeServicesInfo | null {
|
||||
if (typeof value === "string") {
|
||||
const raw = value.trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return parseRuntimeServicesInfo(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = value as RuntimeServicesInfo;
|
||||
if (!parsed.services || typeof parsed.services !== "object") return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseRuntimeServicesInfo(): RuntimeServicesInfo | null {
|
||||
const raw = getRawRuntimeServicesInfo();
|
||||
if (!raw) return null;
|
||||
export async function fetchBackendRuntimeServicesInfo(): Promise<RuntimeServicesInfo | null> {
|
||||
let clientOptions: ReturnType<typeof getAgentServerClientOptions>;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as RuntimeServicesInfo;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return parsed;
|
||||
clientOptions = getAgentServerClientOptions({ timeout: 3000 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = parseRuntimeServicesInfo(
|
||||
getCachedAgentServerInfo({ host: clientOptions.host })?.runtime_services,
|
||||
);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const serverInfo = await new ServerClient(clientOptions).getServerInfo();
|
||||
return parseRuntimeServicesInfo(
|
||||
(serverInfo as { runtime_services?: unknown }).runtime_services,
|
||||
);
|
||||
} catch {
|
||||
// Malformed JSON: ignore and fall back to no runtime info, rather than
|
||||
// tearing down conversation creation over a misconfigured env var or
|
||||
// injected value.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the deployment mode from the runtime services info, e.g. "docker",
|
||||
* "dev:automation", etc. Returns `null` when no runtime info is configured.
|
||||
* "dev:automation", etc. Returns `null` when no runtime info is supplied.
|
||||
*/
|
||||
export function getDeploymentMode(): string | null {
|
||||
return parseRuntimeServicesInfo()?.mode ?? null;
|
||||
export function getDeploymentMode(
|
||||
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
||||
): string | null {
|
||||
return runtimeServicesInfo?.mode ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the runtime services info into a markdown block suitable for
|
||||
* appending to the system prompt via `AgentContext.system_message_suffix`.
|
||||
*
|
||||
* Returns `undefined` when no runtime info is configured, so callers can
|
||||
* safely omit the field on production builds (where the launcher doesn't
|
||||
* set `VITE_RUNTIME_SERVICES_INFO`).
|
||||
* Returns `undefined` when no runtime info is available, so callers can safely
|
||||
* omit the field when the selected backend does not advertise runtime services.
|
||||
*/
|
||||
export function buildRuntimeServicesSystemSuffix(): string | undefined {
|
||||
const info = parseRuntimeServicesInfo();
|
||||
export function buildRuntimeServicesSystemSuffix(
|
||||
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
||||
): string | undefined {
|
||||
const info = parseRuntimeServicesInfo(runtimeServicesInfo);
|
||||
if (!info?.services) return undefined;
|
||||
|
||||
const lines: string[] = [];
|
||||
@@ -658,9 +666,11 @@ function buildBundledSkills(): BundledSkill[] {
|
||||
|
||||
function buildAgentContext(
|
||||
agentSettings: SettingsRecord,
|
||||
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
||||
disabledSkills: string[] = [],
|
||||
): SettingsRecord {
|
||||
const runtimeServicesSuffix = buildRuntimeServicesSystemSuffix();
|
||||
const runtimeServicesSuffix =
|
||||
buildRuntimeServicesSystemSuffix(runtimeServicesInfo);
|
||||
const existingContext = toRecord(agentSettings.agent_context);
|
||||
|
||||
// Merge bundled public skills with any skills already present in the
|
||||
@@ -724,11 +734,16 @@ function resolveAcpCommand(agentSettings: SettingsRecord): unknown {
|
||||
|
||||
function buildConfiguredAcpAgentSettings(
|
||||
settings: Settings,
|
||||
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
||||
): AgentSettingsPayload {
|
||||
const agentSettings = toRecord(settings.agent_settings);
|
||||
const payload: AgentSettingsPayload = {
|
||||
agent_kind: "acp",
|
||||
agent_context: buildAgentContext(agentSettings, settings.disabled_skills),
|
||||
agent_context: buildAgentContext(
|
||||
agentSettings,
|
||||
runtimeServicesInfo,
|
||||
settings.disabled_skills,
|
||||
),
|
||||
};
|
||||
|
||||
// TODO(#1019): set ``acp_isolate_data_dir: true`` here for a containerized
|
||||
@@ -785,6 +800,7 @@ function buildConfiguredAcpAgentSettings(
|
||||
|
||||
function buildConfiguredOpenHandsAgentSettings(
|
||||
settings: Settings,
|
||||
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
||||
): AgentSettingsPayload {
|
||||
const agentSettings = toRecord(settings.agent_settings);
|
||||
const llm = toRecord(agentSettings.llm);
|
||||
@@ -839,17 +855,22 @@ function buildConfiguredOpenHandsAgentSettings(
|
||||
return {
|
||||
...agentSettings,
|
||||
llm,
|
||||
agent_context: buildAgentContext(agentSettings, settings.disabled_skills),
|
||||
agent_context: buildAgentContext(
|
||||
agentSettings,
|
||||
runtimeServicesInfo,
|
||||
settings.disabled_skills,
|
||||
),
|
||||
tools: getAgentTools(agentSettings),
|
||||
};
|
||||
}
|
||||
|
||||
function buildConfiguredAgentSettings(
|
||||
settings: Settings,
|
||||
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
||||
): AgentSettingsPayload {
|
||||
return isAcpAgent(settings)
|
||||
? buildConfiguredAcpAgentSettings(settings)
|
||||
: buildConfiguredOpenHandsAgentSettings(settings);
|
||||
? buildConfiguredAcpAgentSettings(settings, runtimeServicesInfo)
|
||||
: buildConfiguredOpenHandsAgentSettings(settings, runtimeServicesInfo);
|
||||
}
|
||||
|
||||
function buildConfiguredConversationSettings(options: {
|
||||
@@ -935,6 +956,7 @@ export interface StartConversationOptions {
|
||||
agentProfileId?: string;
|
||||
agentProfileKind?: AgentKind;
|
||||
titleLlmProfile?: string;
|
||||
runtimeServicesInfo?: RuntimeServicesInfo | null;
|
||||
}
|
||||
|
||||
export function buildStartConversationRequest(
|
||||
@@ -950,7 +972,10 @@ export function buildStartConversationRequest(
|
||||
: acpMode
|
||||
? "acp"
|
||||
: "openhands";
|
||||
const agentSettings = buildConfiguredAgentSettings(sourceAgentSettings);
|
||||
const agentSettings = buildConfiguredAgentSettings(
|
||||
sourceAgentSettings,
|
||||
options.runtimeServicesInfo,
|
||||
);
|
||||
const acpServerTag = acpMode
|
||||
? getAcpServerTag(sourceAgentSettings)
|
||||
: undefined;
|
||||
@@ -1133,10 +1158,12 @@ export async function buildStartConversationRequestWithEncryptedSettings(options
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const { SecretsService } = await import("./secrets-service");
|
||||
|
||||
const [settingsResult, customSecrets] = await Promise.all([
|
||||
SettingsService.getSettingsForConversation(),
|
||||
SecretsService.getSecrets(),
|
||||
]);
|
||||
const [settingsResult, customSecrets, runtimeServicesInfo] =
|
||||
await Promise.all([
|
||||
SettingsService.getSettingsForConversation(),
|
||||
SecretsService.getSecrets(),
|
||||
fetchBackendRuntimeServicesInfo(),
|
||||
]);
|
||||
|
||||
const { agentSettings, conversationSettings, secretsEncrypted } =
|
||||
settingsResult;
|
||||
@@ -1153,6 +1180,7 @@ export async function buildStartConversationRequestWithEncryptedSettings(options
|
||||
encryptedConversationSettings: conversationSettings,
|
||||
secretsEncrypted,
|
||||
customSecrets,
|
||||
runtimeServicesInfo,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,11 @@ export const AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE =
|
||||
export interface AgentServerInfo extends BaseServerInfo {
|
||||
sdk_version?: string;
|
||||
usable_tools?: string[] | null;
|
||||
runtime_services?: unknown;
|
||||
}
|
||||
|
||||
let cachedAgentServerInfo: AgentServerInfo | null = null;
|
||||
let cachedAgentServerInfoHost: string | null = null;
|
||||
|
||||
const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => {
|
||||
if (Array.isArray(serverInfo?.usable_tools)) {
|
||||
@@ -130,6 +132,16 @@ export const isAgentServerAuthError = (error: unknown): boolean =>
|
||||
|
||||
export function clearCachedAgentServerInfo() {
|
||||
cachedAgentServerInfo = null;
|
||||
cachedAgentServerInfoHost = null;
|
||||
}
|
||||
|
||||
export function getCachedAgentServerInfo(options?: {
|
||||
host?: string | null;
|
||||
}): AgentServerInfo | null {
|
||||
if (options?.host && options.host !== cachedAgentServerInfoHost) {
|
||||
return null;
|
||||
}
|
||||
return cachedAgentServerInfo;
|
||||
}
|
||||
|
||||
export function isAgentServerToolAvailable(toolName: string) {
|
||||
@@ -381,5 +393,6 @@ export async function loadAgentServerInfo() {
|
||||
}
|
||||
|
||||
cachedAgentServerInfo = serverInfo;
|
||||
cachedAgentServerInfoHost = clientOptions.host;
|
||||
return serverInfo;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ export default defineConfig(({ mode }) => {
|
||||
VITE_FRONTEND_PORT = "3001",
|
||||
VITE_INSECURE_SKIP_VERIFY = "false",
|
||||
VITE_BASE_PATH,
|
||||
// Runtime-services metadata for the dev server, passed by launchers that
|
||||
// run the Vite dev server directly (e.g. dev:minimal). Unlike
|
||||
// ingress/static-server, the Vite proxy cannot post-process the upstream
|
||||
// /server_info response, so the launcher serializes the info here and the
|
||||
// middleware below merges it into the proxied response.
|
||||
VITE_RUNTIME_SERVICES_INFO,
|
||||
} = loadEnv(mode, process.cwd());
|
||||
|
||||
const isLibraryBuild = process.env.BUILD_LIB === "true";
|
||||
|
||||
Reference in New Issue
Block a user