diff --git a/AGENTS.md b/AGENTS.md index d9e02baea6..6b6035e499 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,81 @@ - Verification command: `npm run typecheck && npm run build`. - GitHub automation now includes `.github/workflows/ci.yml` for `npm ci`, `npm test`, and `npm run build`, plus `.github/dependabot.yml` with weekly npm/github-actions updates gated by a 7-day cooldown. +## Runtime Services in Dev Stacks + +- When the agent-canvas dev launchers (`npm run dev:safe` / `dev:automation` / `dev:docker` / 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 `` 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:` from inside the sandbox — but that is _you_, not the automation backend. + - Host-side services (ingress, Vite, automation) are reachable as `http://localhost:` in dockerless modes and `http://host.docker.internal:` in `dev:docker`. +- Agents should treat the `` 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-API-Key: $OPENHANDS_AUTOMATION_API_KEY` header. +- The launcher → frontend → suffix plumbing is: + - `scripts/dev-safe.mjs::buildRuntimeServicesInfo()` — pure helper that constructs the info object. + - `scripts/dev-with-automation.mjs::buildAutomationRuntimeServicesInfo()` — wraps it with automation details; called from both Vite spawn (`startVite`) and the static build (`static-build.mjs`). + - `scripts/dev-docker.mjs` and `bin/agent-canvas.mjs` pass `agentHostAlias: "host.docker.internal"` to `main()` because the agent-server runs in a container in those modes. + - `src/api/agent-server-adapter.ts::buildRuntimeServicesSystemSuffix()` reads `VITE_RUNTIME_SERVICES_INFO` and renders the `` markdown block; `createAgentFromSettings()` attaches it to `agent_context.system_message_suffix` when present. + +### `VITE_RUNTIME_SERVICES_INFO` shape + +The env var is a JSON string of: + +```json +{ + "mode": "dev:docker", + "agent_host_alias": "host.docker.internal", + "services": { + "agent_server": { + "description": "The OpenHands Agent Server this agent is running inside. ...", + "url_from_agent": "http://localhost:8000" + }, + "ingress": { + "description": "Unified entry point. Routes /api/automation/* ...", + "url_from_agent": "http://host.docker.internal:8000" + }, + "frontend": { + "kind": "vite", + "description": "Vite dev server hosting the agent-canvas frontend.", + "url_from_agent": "http://host.docker.internal:3001" + }, + "automation": { + "description": "OpenHands Automations service. All routes are mounted under '/api/automation'. Authenticate with header 'X-API-Key: $OPENHANDS_AUTOMATION_API_KEY'.", + "url_from_agent": "http://host.docker.internal:18001", + "api_prefix": "/api/automation", + "docs_url": "http://host.docker.internal:18001/api/automation/docs", + "openapi_url": "http://host.docker.internal:18001/api/automation/openapi.json", + "auth_env_var": "OPENHANDS_AUTOMATION_API_KEY" + } + } +} +``` + +All keys under `services` are optional and omitted when the corresponding service isn't running. `frontend.kind` is `"vite"` for dev launchers running the Vite dev server and `"static"` for stacks serving a pre-built `build/` directory (`dev:docker`, `dev:dangerously-dockerless`, the published `agent-canvas` binary). `services.vite` is accepted as a legacy alias for `services.frontend` by the renderer. + +### Example `` block (dev:docker with automation) + +``` + +You are running inside an agent-canvas dev stack started in 'dev:docker' mode. +The following services are reachable from your sandbox. URLs are written +from your point of view (i.e., as you should curl/fetch them). + +* Agent Server (you): http://localhost:8000 + The OpenHands Agent Server this agent is running inside. Tool calls (terminal, file_editor, browser, etc.) execute here. +* Ingress: http://host.docker.internal:8000 + Unified entry point. Routes /api/automation/* to the automation backend, /api/* and /sockets to the agent-server, and /* to the frontend. +* Frontend: http://host.docker.internal:3001 + Static-file server hosting the agent-canvas production build. +* Automation backend: http://host.docker.internal:18001 + OpenHands Automations service. All routes are mounted under '/api/automation'. Authenticate with header 'X-API-Key: $OPENHANDS_AUTOMATION_API_KEY'. + Docs: http://host.docker.internal:18001/api/automation/docs + OpenAPI: http://host.docker.internal:18001/api/automation/openapi.json + Auth: header 'X-API-Key: $OPENHANDS_AUTOMATION_API_KEY' + +Trust this block over guessing: do not assume any other URLs are running. +In particular, http://localhost:8000 inside your sandbox is the Agent Server +you are running inside of — NOT the automation backend. + +``` + ## Visual Snapshot Testing - Snapshot tests live in `tests/e2e/snapshots/` and compare screenshots against baselines stored as GitHub Actions artifacts (NOT in git). diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index 3bdab168ab..4af700b203 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -1,6 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + buildRuntimeServicesSystemSuffix, buildStartConversationRequest, getDefaultConversationTitle, toAppConversation, @@ -13,9 +14,7 @@ const { mockIsAgentServerToolAvailable, mockGetEffectiveLocalBackend, } = vi.hoisted(() => ({ - mockGetAgentServerWorkingDir: vi.fn( - () => "/workspace/project/agent-canvas", - ), + mockGetAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"), mockIsAgentServerToolAvailable: vi.fn(() => true), mockGetEffectiveLocalBackend: vi.fn(() => ({ id: "default-local", @@ -151,7 +150,6 @@ describe("buildStartConversationRequest", () => { expect(payload.agent.enable_switch_llm_tool).toBeUndefined(); }); - it("omits browser_tool_set when the server does not advertise browser support", () => { mockIsAgentServerToolAvailable.mockReturnValue(false); @@ -320,7 +318,6 @@ describe("buildStartConversationRequest", () => { }, }); }); - }); describe("getDefaultConversationTitle", () => { @@ -366,3 +363,191 @@ describe("toAppConversation", () => { expect(result.title).toBe("My real title"); }); }); + +describe("buildRuntimeServicesSystemSuffix", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns undefined when VITE_RUNTIME_SERVICES_INFO is unset", () => { + 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("returns undefined when the JSON has no services", () => { + vi.stubEnv("VITE_RUNTIME_SERVICES_INFO", JSON.stringify({ mode: "x" })); + expect(buildRuntimeServicesSystemSuffix()).toBeUndefined(); + }); + + it("renders a block when an automation entry is present", () => { + vi.stubEnv( + "VITE_RUNTIME_SERVICES_INFO", + JSON.stringify({ + mode: "dev:docker", + agent_host_alias: "host.docker.internal", + services: { + agent_server: { + description: "self", + url_from_agent: "http://localhost:8000", + }, + automation: { + description: "automations", + url_from_agent: "http://host.docker.internal:18001", + api_prefix: "/api/automation", + docs_url: "http://host.docker.internal:18001/api/automation/docs", + openapi_url: + "http://host.docker.internal:18001/api/automation/openapi.json", + auth_env_var: "OPENHANDS_AUTOMATION_API_KEY", + }, + }, + }), + ); + const suffix = buildRuntimeServicesSystemSuffix(); + expect(suffix).toBeDefined(); + expect(suffix).toContain(""); + expect(suffix).toContain("dev:docker"); + expect(suffix).toContain("http://localhost:8000"); + expect(suffix).toContain("http://host.docker.internal:18001"); + expect(suffix).toContain( + "http://host.docker.internal:18001/api/automation/docs", + ); + expect(suffix).toContain("X-API-Key: $OPENHANDS_AUTOMATION_API_KEY"); + expect(suffix).toContain(""); + // The "don't guess" line should reference the actual agent-server URL + // for this stack, not a hardcoded :8000. We pinned :8000 here but the + // assertion specifically anchors on the URL we supplied. + expect(suffix).toContain( + "In particular, http://localhost:8000 inside your sandbox is the Agent Server", + ); + }); + + 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(); + expect(suffix).toBeDefined(); + expect(suffix).toContain( + "In particular, http://localhost:18000 inside your sandbox is the Agent Server", + ); + expect(suffix).not.toContain( + "In particular, http://localhost:8000 inside your sandbox", + ); + }); + + it("renders the frontend entry with the new key", () => { + vi.stubEnv( + "VITE_RUNTIME_SERVICES_INFO", + JSON.stringify({ + mode: "dev:docker", + services: { + agent_server: { url_from_agent: "http://localhost:8000" }, + frontend: { + kind: "static", + description: "Static-file server hosting the agent-canvas build.", + url_from_agent: "http://host.docker.internal:3001", + }, + }, + }), + ); + const suffix = buildRuntimeServicesSystemSuffix(); + expect(suffix).toContain("* Frontend: http://host.docker.internal:3001"); + expect(suffix).toContain("Static-file server"); + // Should NOT mislabel a static-build frontend as "Vite frontend". + expect(suffix).not.toContain("Vite frontend"); + }); + + it("accepts the legacy `vite` service key", () => { + // Older launchers may still emit `services.vite`. Render it under the + // new "Frontend" label rather than dropping the entry. + vi.stubEnv( + "VITE_RUNTIME_SERVICES_INFO", + JSON.stringify({ + mode: "dev:safe", + services: { + agent_server: { url_from_agent: "http://localhost:18000" }, + vite: { + description: "Vite dev server", + url_from_agent: "http://localhost:3001", + }, + }, + }), + ); + const suffix = buildRuntimeServicesSystemSuffix(); + expect(suffix).toContain("* Frontend: http://localhost:3001"); + }); + + 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(); + expect(suffix).toBeDefined(); + expect(suffix).toContain("Automation backend: not running"); + }); +}); + +describe("createAgentFromSettings 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, + query: "hello", + }) as { + agent: { agent_context: Record }; + }; + expect(payload.agent.agent_context).toEqual({ + load_public_skills: true, + load_user_skills: true, + }); + }); + + it("sets system_message_suffix when runtime info is provided", () => { + vi.stubEnv( + "VITE_RUNTIME_SERVICES_INFO", + JSON.stringify({ + mode: "dev:docker", + services: { + agent_server: { url_from_agent: "http://localhost:8000" }, + automation: { + url_from_agent: "http://host.docker.internal:18001", + }, + }, + }), + ); + const payload = buildStartConversationRequest({ + settings: DEFAULT_SETTINGS, + query: "hello", + }) as { + agent: { agent_context: Record }; + }; + expect(payload.agent.agent_context).toMatchObject({ + load_public_skills: true, + load_user_skills: true, + }); + expect( + payload.agent.agent_context.system_message_suffix as string, + ).toContain(""); + }); +}); diff --git a/__tests__/scripts/dev-safe.test.ts b/__tests__/scripts/dev-safe.test.ts index 3abccc05d4..929851ee27 100644 --- a/__tests__/scripts/dev-safe.test.ts +++ b/__tests__/scripts/dev-safe.test.ts @@ -12,6 +12,7 @@ import { buildSafeDevConfigAsync, buildNpmScriptCommand, buildAgentServerCommand, + buildRuntimeServicesInfo, formatMissingUvxGuidance, formatMissingFrontendDependenciesGuidance, getMissingFrontendDependencyBins, @@ -287,7 +288,9 @@ describe("formatMissingUvxGuidance", () => { "/workspace/project/agent-canvas", ); - expect(guidance).toContain("curl -LsSf https://astral.sh/uv/install.sh | sh"); + expect(guidance).toContain( + "curl -LsSf https://astral.sh/uv/install.sh | sh", + ); expect(guidance).toContain('export PATH="$HOME/.local/bin:$PATH"'); expect(guidance).toContain("command -v uvx"); expect(guidance).toContain( @@ -338,7 +341,9 @@ describe("buildAgentServerCommand", () => { }); it("uses git ref with subdirectory syntax for monorepo", () => { - const cmd = buildAgentServerCommand({ OH_AGENT_SERVER_GIT_REF: "feature-branch" }); + const cmd = buildAgentServerCommand({ + OH_AGENT_SERVER_GIT_REF: "feature-branch", + }); expect(cmd.command).toBe("uvx"); expect(cmd.args).toEqual([ @@ -736,3 +741,154 @@ describe("dev-safe CLI startup", () => { expect(output).toContain("spawn uvx ENOENT"); }); }); + +interface RuntimeServiceEntry { + kind?: string; + description?: string; + url_from_agent?: string; + api_prefix?: string; + docs_url?: string; + openapi_url?: string; + auth_env_var?: string; +} +interface RuntimeServicesInfoShape { + mode: string; + agent_host_alias: string; + services: { + agent_server?: RuntimeServiceEntry; + ingress?: RuntimeServiceEntry; + frontend?: RuntimeServiceEntry; + automation?: RuntimeServiceEntry; + }; +} + +describe("buildRuntimeServicesInfo", () => { + it("describes only the agent-server in a minimal dev-safe stack", () => { + const info = buildRuntimeServicesInfo({ + mode: "dev:safe", + agentServerPort: 18000, + }) as RuntimeServicesInfoShape; + expect(info).toEqual({ + mode: "dev:safe", + agent_host_alias: "localhost", + services: { + agent_server: { + description: expect.any(String), + url_from_agent: "http://localhost:18000", + }, + }, + }); + }); + + it("includes ingress, frontend (vite), and automation entries when ports are provided", () => { + const info = buildRuntimeServicesInfo({ + mode: "dev:automation", + agentServerPort: 18000, + ingressPort: 8000, + frontendPort: 3001, + automation: { port: 18001 }, + }) as RuntimeServicesInfoShape; + expect(info.services.ingress?.url_from_agent).toBe("http://localhost:8000"); + expect(info.services.frontend).toMatchObject({ + kind: "vite", + url_from_agent: "http://localhost:3001", + }); + expect(info.services.frontend?.description).toMatch(/Vite dev server/i); + expect(info.services.automation).toMatchObject({ + 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", + }); + }); + + it("uses host.docker.internal as the agent host alias in docker mode", () => { + const info = buildRuntimeServicesInfo({ + mode: "dev:docker", + agentHostAlias: "host.docker.internal", + agentServerPort: 8000, + ingressPort: 8000, + frontendPort: 3001, + frontendKind: "static", + automation: { port: 18001 }, + }) as RuntimeServicesInfoShape; + // Agent-server URL is always localhost (the agent is *inside* it). + expect(info.services.agent_server?.url_from_agent).toBe( + "http://localhost:8000", + ); + // Host-side services use the docker alias. + expect(info.services.ingress?.url_from_agent).toBe( + "http://host.docker.internal:8000", + ); + expect(info.services.frontend?.url_from_agent).toBe( + "http://host.docker.internal:3001", + ); + // Static-mode description, not "Vite dev server". + expect(info.services.frontend?.kind).toBe("static"); + expect(info.services.frontend?.description).toMatch(/Static-file server/i); + expect(info.services.frontend?.description).not.toMatch(/Vite/i); + expect(info.services.automation?.url_from_agent).toBe( + "http://host.docker.internal:18001", + ); + }); + + it("allows overriding the api prefix and auth env var", () => { + const info = buildRuntimeServicesInfo({ + mode: "dev:custom", + agentServerPort: 18000, + automation: { + port: 9000, + apiPrefix: "/v2/auto", + authEnvVar: "MY_KEY", + }, + }) as RuntimeServicesInfoShape; + expect(info.services.automation).toMatchObject({ + api_prefix: "/v2/auto", + docs_url: "http://localhost:9000/v2/auto/docs", + openapi_url: "http://localhost:9000/v2/auto/openapi.json", + auth_env_var: "MY_KEY", + }); + }); + + it("omits the automation entry when none is provided", () => { + const info = buildRuntimeServicesInfo({ + mode: "dev:safe", + agentServerPort: 18000, + ingressPort: 8000, + }) as RuntimeServicesInfoShape; + expect(info.services.automation).toBeUndefined(); + }); + + it("omits the automation entry when the object lacks a port", () => { + // A bare `{}` previously slipped through and produced + // `http://localhost:undefined`; require the port explicitly. + const info = buildRuntimeServicesInfo({ + mode: "dev:safe", + agentServerPort: 18000, + automation: {}, + }) as RuntimeServicesInfoShape; + expect(info.services.automation).toBeUndefined(); + }); + + it("throws when agentServerPort is missing", () => { + expect(() => + buildRuntimeServicesInfo({ + mode: "dev:safe", + }), + ).toThrow(/agentServerPort is required/); + }); + + it("accepts the legacy vitePort alias for frontendPort", () => { + // dev-safe.mjs's `main()` and some external callers still pass the + // older option name; keep them working for one release. + const info = buildRuntimeServicesInfo({ + mode: "dev:safe", + agentServerPort: 18000, + vitePort: 3001, + }) as RuntimeServicesInfoShape; + expect(info.services.frontend?.url_from_agent).toBe( + "http://localhost:3001", + ); + }); +}); diff --git a/bin/agent-canvas.mjs b/bin/agent-canvas.mjs index c9076e2ff3..686ff15b33 100755 --- a/bin/agent-canvas.mjs +++ b/bin/agent-canvas.mjs @@ -81,11 +81,8 @@ this is a packaging error. If running from source: let main, checkDockerPrereqs, startAgentServerDocker, CONTAINER_WORKSPACES_DIR; try { ({ main } = await import("../scripts/dev-with-automation.mjs")); - ({ - checkDockerPrereqs, - startAgentServerDocker, - CONTAINER_WORKSPACES_DIR, - } = await import("../scripts/dev-docker.mjs")); + ({ checkDockerPrereqs, startAgentServerDocker, CONTAINER_WORKSPACES_DIR } = + await import("../scripts/dev-docker.mjs")); } catch (err) { console.error("Failed to load required scripts. Try reinstalling:"); console.error(" npm install -g @openhands/agent-canvas@latest"); @@ -100,6 +97,10 @@ main({ viteWorkingDir: CONTAINER_WORKSPACES_DIR, staticMode: true, staticDir: BUILD_DIR, + // Agent-server runs in a Docker container; host services are reached + // via "host.docker.internal" from the agent's POV. + agentHostAlias: "host.docker.internal", + mode: "agent-canvas", }).catch((err) => { console.error(`Fatal error: ${err.message}`); if (err.stack) { diff --git a/scripts/dev-docker.mjs b/scripts/dev-docker.mjs index a3283c9ec2..ff6e5224aa 100644 --- a/scripts/dev-docker.mjs +++ b/scripts/dev-docker.mjs @@ -365,6 +365,11 @@ if (isMainModule) { viteWorkingDir: CONTAINER_WORKSPACES_DIR, defaultStaticMode: true, buildStaticFrontend: buildFrontend, + // The agent-server runs inside a Docker container in this mode, so + // host services (ingress, automation, vite) are reachable via + // "host.docker.internal" rather than "localhost" from the agent's POV. + agentHostAlias: "host.docker.internal", + mode: "dev:docker", }).catch((err) => { logError(`Fatal error: ${err.message}`); if (err.stack) { diff --git a/scripts/dev-safe.mjs b/scripts/dev-safe.mjs index b6892c6612..b456648af3 100644 --- a/scripts/dev-safe.mjs +++ b/scripts/dev-safe.mjs @@ -454,10 +454,7 @@ export function buildSafeDevConfig(cwd = process.cwd(), env = process.env) { env.OH_CANVAS_SAFE_BACKEND_PORT, DEFAULT_BACKEND_PORT, ); - const vscodePort = parsePort( - env.OH_CANVAS_SAFE_VSCODE_PORT, - backendPort + 1, - ); + const vscodePort = parsePort(env.OH_CANVAS_SAFE_VSCODE_PORT, backendPort + 1); return buildConfigFromPorts({ backendPort, vscodePort }, cwd, env); } @@ -603,6 +600,124 @@ export function buildAgentServerEnv(config) { }; } +/** + * Build a structured description of the dev-stack services that are + * reachable from inside the agent's sandbox. The frontend forwards this + * (verbatim, as a JSON string in `VITE_RUNTIME_SERVICES_INFO`) and renders + * it into the system prompt via `AgentContext.system_message_suffix`, so + * the agent sees a `` block listing what's available + * without having to probe. + * + * URLs are written from the *agent's* point of view. In dev-safe / + * dev-with-automation the agent-server runs on the host, so the host + * alias is "localhost". In dev-docker the agent-server runs inside a + * container and reaches host services via "host.docker.internal". + * + * @param {object} options + * @param {string} [options.mode] - Human-readable dev mode label (e.g. "dev:safe"). + * @param {string} [options.agentHostAlias="localhost"] - Hostname the agent + * uses to reach services running on the host machine. + * @param {number} [options.agentServerPort] - Port the agent-server listens on. + * Required at runtime; the function throws if missing because the resulting + * URL would otherwise bake `undefined` into the agent's system prompt. + * Typed as optional only so TypeScript callers can negative-test the guard. + * @param {number} [options.ingressPort] - Ingress port (omit if no ingress). + * @param {number} [options.frontendPort] - Frontend port (Vite dev server + * or static-file server). Omit if no frontend is exposed. + * @param {number} [options.vitePort] - Deprecated alias for `frontendPort`, + * accepted for backward compat with older launchers. Remove after one release. + * @param {"vite"|"static"} [options.frontendKind="vite"] - Whether the + * frontend port hosts Vite or a static build. Only affects the + * description shown to the agent. + * @param {object} [options.automation] - Automation backend info. Skipped + * entirely if `.port` is missing, so passing `{}` is safe. + * @param {number} [options.automation.port] - Automation backend port. + * @param {string} [options.automation.apiPrefix="/api/automation"] - Path + * prefix all automation routes are mounted under. + * @param {string} [options.automation.authEnvVar="OPENHANDS_AUTOMATION_API_KEY"] + * - Env var holding the API key. + * @returns {object} A JSON-serializable runtime services info object. + */ +export function buildRuntimeServicesInfo(options) { + const { + mode, + agentHostAlias = "localhost", + agentServerPort, + ingressPort, + // Accept legacy `vitePort` for one release so external callers keep working. + vitePort, + frontendPort = vitePort, + frontendKind = "vite", + automation, + } = options; + + if (agentServerPort === undefined || agentServerPort === null) { + // Without this the URL becomes `http://localhost:undefined` and ends up + // verbatim in the agent's system prompt, which is worse than failing fast. + throw new Error( + "buildRuntimeServicesInfo: agentServerPort is required " + + "(otherwise the agent_server URL would be `http://localhost:undefined`).", + ); + } + + const services = { + agent_server: { + description: + "The OpenHands Agent Server this agent is running inside. " + + "Tool calls (terminal, file_editor, browser, etc.) execute here.", + // From the agent's POV, the agent-server it's *inside* is on + // localhost, regardless of where the host is. + url_from_agent: `http://localhost:${agentServerPort}`, + }, + }; + + if (ingressPort !== undefined) { + services.ingress = { + description: + "Unified entry point. Routes /api/automation/* to the automation " + + "backend, /api/* and /sockets to the agent-server, and /* to the " + + "frontend.", + url_from_agent: `http://${agentHostAlias}:${ingressPort}`, + }; + } + + if (frontendPort !== undefined) { + services.frontend = { + kind: frontendKind, + description: + frontendKind === "static" + ? "Static-file server hosting the agent-canvas production build." + : "Vite dev server hosting the agent-canvas frontend.", + url_from_agent: `http://${agentHostAlias}:${frontendPort}`, + }; + } + + // Require an explicit port so we don't bake `:undefined` into the + // automation URL when the caller passes `automation: {}`. + if (automation?.port !== undefined && automation.port !== null) { + const apiPrefix = automation.apiPrefix ?? "/api/automation"; + const authEnvVar = automation.authEnvVar ?? "OPENHANDS_AUTOMATION_API_KEY"; + const baseUrl = `http://${agentHostAlias}:${automation.port}`; + services.automation = { + description: + "OpenHands Automations service. All routes are mounted under " + + `'${apiPrefix}'. Authenticate with header ` + + `'X-API-Key: $${authEnvVar}'.`, + url_from_agent: baseUrl, + api_prefix: apiPrefix, + docs_url: `${baseUrl}${apiPrefix}/docs`, + openapi_url: `${baseUrl}${apiPrefix}/openapi.json`, + auth_env_var: authEnvVar, + }; + } + + return { + mode, + agent_host_alias: agentHostAlias, + services, + }; +} + export function buildNpmScriptCommand( scriptName, platform = process.platform, @@ -636,9 +751,7 @@ export function validateLocalAgentServerPath(localPath) { ); } if (!existsSync(localPath)) { - throw new Error( - `OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`, - ); + throw new Error(`OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`); } for (const subdir of LOCAL_AGENT_SERVER_SUBDIRS) { const subdirPath = path.join(localPath, subdir); @@ -670,10 +783,14 @@ async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) { } function spawnProcess(command, args, options = {}) { - const child = spawn(command, args, getProcessTreeSpawnOptions({ - stdio: "inherit", - ...options, - })); + const child = spawn( + command, + args, + getProcessTreeSpawnOptions({ + stdio: "inherit", + ...options, + }), + ); child.once("error", (error) => { if (isEnoentError(error) && command === "uvx") { @@ -810,6 +927,10 @@ 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: { @@ -819,6 +940,9 @@ 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), }, }); diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs index 8dc5460133..46b30a26e9 100644 --- a/scripts/dev-with-automation.mjs +++ b/scripts/dev-with-automation.mjs @@ -51,6 +51,7 @@ import { buildSafeDevConfig, buildAgentServerEnv, buildNpmScriptCommand, + buildRuntimeServicesInfo, formatMissingUvxGuidance, findFreePorts, getOrCreatePersistedApiKey, @@ -671,10 +672,32 @@ 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 + * `` system-prompt block. + */ +export function buildAutomationRuntimeServicesInfo(config) { + return buildRuntimeServicesInfo({ + mode: config.mode ?? "dev:automation", + agentHostAlias: config.agentHostAlias ?? "localhost", + agentServerPort: config.agentServerPort, + ingressPort: config.ingressPort, + frontendPort: config.vitePort, + // The same port hosts Vite in dynamic mode and a static-file server + // in static mode. The launcher records this on the config so the + // description shown to the agent matches reality. + frontendKind: config.frontendKind ?? "vite", + automation: { port: config.autoBackendPort }, + }); +} + function startVite(config) { logService("vite", `Starting on port ${config.vitePort}...`, c.magenta); const frontendCommand = buildNpmScriptCommand("dev:frontend"); + const runtimeServicesInfo = buildAutomationRuntimeServicesInfo(config); spawnService("vite", frontendCommand.command, frontendCommand.args, { cwd: config.canvasPath, @@ -689,6 +712,9 @@ function startVite(config) { VITE_SESSION_API_KEY: config.sessionApiKey, // Automation API key for frontend to authenticate with automation backend VITE_AUTOMATION_API_KEY: config.localApiKey, + // 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), // Session API key for agent-server auth (when SESSION_API_KEY is set) ...(config.sessionApiKey && { VITE_SESSION_API_KEY: config.sessionApiKey, @@ -840,6 +866,14 @@ async function main(options = {}) { defaultStaticMode = false, buildStaticFrontend, staticDir: staticDirOverride, + // Hostname the agent uses to reach services running on the host. + // dev-docker.mjs overrides this to "host.docker.internal" because the + // agent-server runs in a container and the host is not "localhost" + // from its perspective. + agentHostAlias = "localhost", + // Human-readable label for the dev mode, surfaced in the agent's + // system-prompt block. + mode = "dev:automation", } = options; const args = parseArgs(); @@ -869,6 +903,12 @@ async function main(options = {}) { // Build config with dynamic port allocation const config = await buildConfig(args); if (viteWorkingDir) config.viteWorkingDir = viteWorkingDir; + // Stamp the dev-mode label, host alias, and frontend kind on the config + // so downstream helpers (Vite spawn, static build) can produce a + // runtime-services info object describing what the agent can reach. + config.mode = mode; + config.agentHostAlias = agentHostAlias; + config.frontendKind = useStaticMode ? "static" : "vite"; ensureDirectories(config); if (typeof extraPrereqs === "function") { extraPrereqs(config); diff --git a/scripts/static-build.mjs b/scripts/static-build.mjs index 3d094331e8..5f60f1b6f1 100644 --- a/scripts/static-build.mjs +++ b/scripts/static-build.mjs @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { + buildAutomationRuntimeServicesInfo, c, logError, logService, @@ -50,6 +51,12 @@ export function buildFrontend(config, args = {}) { // a fresh browser session seeds the Local backend with an empty key and // all authenticated agent-server calls fail with 401. 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 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. diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index f7f9885a2f..b768b8cc16 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -51,6 +51,146 @@ function browserToolsEnabled() { return import.meta.env.VITE_ENABLE_BROWSER_TOOLS !== "false"; } +/** + * Shape of `VITE_RUNTIME_SERVICES_INFO` (set by the dev launchers in + * scripts/dev-*.mjs). 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. + */ +interface RuntimeServicesInfo { + mode?: string; + agent_host_alias?: string; + services?: { + agent_server?: { description?: string; url_from_agent?: string }; + ingress?: { description?: string; url_from_agent?: string }; + frontend?: { + kind?: "vite" | "static"; + description?: string; + url_from_agent?: string; + }; + // `vite` is the legacy key name for the frontend entry, accepted for + // one release while older dev-stack launchers may still emit it. + vite?: { description?: string; url_from_agent?: string }; + automation?: { + description?: string; + url_from_agent?: string; + api_prefix?: string; + docs_url?: string; + openapi_url?: string; + auth_env_var?: string; + }; + }; +} + +function parseRuntimeServicesInfo(): RuntimeServicesInfo | null { + const raw = import.meta.env.VITE_RUNTIME_SERVICES_INFO?.trim(); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as RuntimeServicesInfo; + if (!parsed || typeof parsed !== "object") return null; + return parsed; + } catch { + // Malformed JSON: ignore and fall back to no runtime info, rather than + // tearing down conversation creation over a misconfigured dev env var. + return 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`). + */ +export function buildRuntimeServicesSystemSuffix(): string | undefined { + const info = parseRuntimeServicesInfo(); + if (!info?.services) return undefined; + + const lines: string[] = []; + lines.push(""); + if (info.mode) { + lines.push( + `You are running inside an agent-canvas dev stack started in '${info.mode}' mode.`, + ); + } else { + lines.push("You are running inside an agent-canvas dev stack."); + } + lines.push( + "The following services are reachable from your sandbox. URLs are written", + "from your point of view (i.e., as you should curl/fetch them).", + "", + ); + + const { agent_server, ingress, automation } = info.services; + // Accept `frontend` (current key) or `vite` (legacy key) for the + // frontend service entry. The legacy fallback can be removed once all + // launchers in this repo emit `frontend`. + const frontend = info.services.frontend ?? info.services.vite; + + if (agent_server?.url_from_agent) { + lines.push( + `* Agent Server (you): ${agent_server.url_from_agent}`, + ` ${agent_server.description ?? "The agent-server hosting your tool calls."}`, + ); + } + if (ingress?.url_from_agent) { + lines.push( + `* Ingress: ${ingress.url_from_agent}`, + ` ${ingress.description ?? "Unified entry point for browser-facing traffic."}`, + ); + } + if (frontend?.url_from_agent) { + lines.push( + `* Frontend: ${frontend.url_from_agent}`, + ` ${frontend.description ?? "Frontend dev server."}`, + ); + } + if (automation?.url_from_agent) { + lines.push( + `* Automation backend: ${automation.url_from_agent}`, + ` ${automation.description ?? "OpenHands Automations service."}`, + ); + if (automation.docs_url) { + lines.push(` Docs: ${automation.docs_url}`); + } + if (automation.openapi_url) { + lines.push(` OpenAPI: ${automation.openapi_url}`); + } + if (automation.auth_env_var) { + lines.push( + ` Auth: header 'X-API-Key: $${automation.auth_env_var}'`, + ); + } + } else { + lines.push( + "* Automation backend: not running in this dev mode (skip /api/automation calls).", + ); + } + + // Anchor the "don't guess" warning to the actual agent-server URL for + // this stack instead of a hardcoded port. The agent-server listens on + // different ports across dev modes (18000 in dev:safe, 8000 in + // dev:docker, ...), and baking the wrong port into the system prompt + // is exactly the kind of confusion this block is meant to prevent. + const agentServerUrl = agent_server?.url_from_agent; + lines.push( + "", + "Trust this block over guessing: do not assume any other URLs are running.", + ); + if (agentServerUrl) { + lines.push( + `In particular, ${agentServerUrl} inside your sandbox is the Agent Server`, + "you are running inside of — NOT the automation backend.", + ); + } + lines.push(""); + + return lines.join("\n"); +} + export function toConversationUrl(conversationId: string): string { // Local-format conversation URL — points at whichever local agent-server // is actually serving the conversation (the bundled one when the active @@ -309,12 +449,20 @@ function buildConfiguredAgentSettings(settings: Settings): SettingsRecord { } function createAgentFromSettings(agentSettings: SettingsRecord) { + const runtimeServicesSuffix = buildRuntimeServicesSystemSuffix(); return { kind: "Agent", ...agentSettings, agent_context: { load_public_skills: true, load_user_skills: true, + // When the dev launcher provided `VITE_RUNTIME_SERVICES_INFO`, append + // a block to the system prompt so the agent knows + // which services exist in this dev stack (e.g. automation backend + // URL, ingress URL) instead of having to probe. + ...(runtimeServicesSuffix + ? { system_message_suffix: runtimeServicesSuffix } + : {}), }, }; }