diff --git a/scripts/test-live-subagent-announce-docker.sh b/scripts/test-live-subagent-announce-docker.sh index 6ca35209772..0c2183d04cb 100644 --- a/scripts/test-live-subagent-announce-docker.sh +++ b/scripts/test-live-subagent-announce-docker.sh @@ -55,10 +55,10 @@ if [[ -f "$PROFILE_FILE" && -r "$PROFILE_FILE" ]]; then PROFILE_STATUS="$PROFILE_FILE" fi -if [[ -n "${OPENAI_API_KEY:-}" || -n "${OPENAI_BASE_URL:-}" ]]; then +if [[ -n "${OPENAI_API_KEY:-}" || -n "${OPENAI_BASE_URL:-}" || -n "${GEMINI_API_KEY:-}" || -n "${GOOGLE_API_KEY:-}" ]]; then docker_env_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-subagent-live-env.XXXXXX")" TEMP_DIRS+=("$docker_env_dir") - docker_env_file="$docker_env_dir/openai.env" + docker_env_file="$docker_env_dir/provider.env" { if [[ -n "${OPENAI_API_KEY:-}" ]]; then printf 'OPENCLAW_DOCKER_LIVE_OPENAI_API_KEY=%s\n' "${OPENAI_API_KEY}" @@ -66,6 +66,12 @@ if [[ -n "${OPENAI_API_KEY:-}" || -n "${OPENAI_BASE_URL:-}" ]]; then if [[ -n "${OPENAI_BASE_URL:-}" ]]; then printf 'OPENCLAW_DOCKER_LIVE_OPENAI_BASE_URL=%s\n' "${OPENAI_BASE_URL}" fi + if [[ -n "${GEMINI_API_KEY:-}" ]]; then + printf 'OPENCLAW_DOCKER_LIVE_GEMINI_API_KEY=%s\n' "${GEMINI_API_KEY}" + fi + if [[ -n "${GOOGLE_API_KEY:-}" ]]; then + printf 'OPENCLAW_DOCKER_LIVE_GOOGLE_API_KEY=%s\n' "${GOOGLE_API_KEY}" + fi } >"$docker_env_file" DOCKER_EXTRA_ENV_FILES+=(--env-file "$docker_env_file") fi @@ -87,6 +93,14 @@ if [ -n "${OPENCLAW_DOCKER_LIVE_OPENAI_BASE_URL:-}" ]; then export OPENAI_BASE_URL="$OPENCLAW_DOCKER_LIVE_OPENAI_BASE_URL" unset OPENCLAW_DOCKER_LIVE_OPENAI_BASE_URL fi +if [ -n "${OPENCLAW_DOCKER_LIVE_GEMINI_API_KEY:-}" ]; then + export GEMINI_API_KEY="$OPENCLAW_DOCKER_LIVE_GEMINI_API_KEY" + unset OPENCLAW_DOCKER_LIVE_GEMINI_API_KEY +fi +if [ -n "${OPENCLAW_DOCKER_LIVE_GOOGLE_API_KEY:-}" ]; then + export GOOGLE_API_KEY="$OPENCLAW_DOCKER_LIVE_GOOGLE_API_KEY" + unset OPENCLAW_DOCKER_LIVE_GOOGLE_API_KEY +fi export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" export COREPACK_HOME="${COREPACK_HOME:-$XDG_CACHE_HOME/node/corepack}" export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" diff --git a/src/agents/subagent-announce.live.test.ts b/src/agents/subagent-announce.live.test.ts index 837c6608e4a..081ae960897 100644 --- a/src/agents/subagent-announce.live.test.ts +++ b/src/agents/subagent-announce.live.test.ts @@ -1,4 +1,5 @@ import { randomBytes, randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { clearRuntimeConfigSnapshot, type OpenClawConfig } from "../config/config.js"; @@ -38,12 +39,90 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function openAiConfig( +type LiveSubagentModelConfig = { + modelKey: string; + provider: "openai" | "google"; + requiredEnv: "OPENAI_API_KEY" | "GEMINI_API_KEY" | "GOOGLE_API_KEY"; +}; +type LiveSubagentModelProviders = NonNullable["providers"]>; + +function resolveLiveSubagentModelConfig(): LiveSubagentModelConfig { + const modelKey = process.env.OPENCLAW_LIVE_SUBAGENT_E2E_MODEL?.trim() || "openai/gpt-5.5"; + if (modelKey.startsWith("google/")) { + return { + modelKey, + provider: "google", + requiredEnv: process.env.GEMINI_API_KEY?.trim() ? "GEMINI_API_KEY" : "GOOGLE_API_KEY", + }; + } + return { modelKey, provider: "openai", requiredEnv: "OPENAI_API_KEY" }; +} + +function requireLiveSubagentAuth(config: LiveSubagentModelConfig): void { + expect(process.env[config.requiredEnv]?.trim(), config.requiredEnv).toBeTruthy(); +} + +function liveSubagentConfig( modelKey: string, workspace: string, port: number, token: string, + options?: { toolAllow?: string[] }, ): OpenClawConfig { + const providerConfig = resolveLiveSubagentModelConfig(); + const modelId = modelKey.replace(/^(openai|google)\//u, ""); + const providers: LiveSubagentModelProviders = {}; + if (providerConfig.provider === "google") { + providers.google = { + api: "google-generative-ai" as const, + agentRuntime: { id: "pi" }, + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + apiKey: { + source: "env" as const, + provider: "default" as const, + id: providerConfig.requiredEnv, + }, + timeoutSeconds: 300, + models: [ + { + id: modelId, + name: modelId, + api: "google-generative-ai" as const, + agentRuntime: { id: "pi" }, + input: ["text" as const], + reasoning: true, + contextWindow: 1_048_576, + maxTokens: 8_192, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }; + } else { + providers.openai = { + api: "openai-responses" as const, + agentRuntime: { id: "pi" }, + apiKey: { + source: "env" as const, + provider: "default" as const, + id: "OPENAI_API_KEY", + }, + baseUrl: "https://api.openai.com/v1", + timeoutSeconds: 300, + models: [ + { + id: modelId, + name: modelId, + api: "openai-responses" as const, + agentRuntime: { id: "pi" }, + input: ["text" as const], + reasoning: true, + contextWindow: 1_047_576, + maxTokens: 8_192, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }; + } return { gateway: { mode: "local", @@ -52,32 +131,9 @@ function openAiConfig( controlUi: { enabled: false }, }, plugins: { enabled: false }, - tools: { - allow: ["sessions_spawn", "sessions_yield", "subagents"], - }, + tools: { allow: options?.toolAllow ?? ["sessions_spawn", "sessions_yield", "subagents"] }, models: { - providers: { - openai: { - api: "openai-responses", - agentRuntime: { id: "pi" }, - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - baseUrl: "https://api.openai.com/v1", - timeoutSeconds: 300, - models: [ - { - id: modelKey.replace(/^openai\//u, ""), - name: modelKey.replace(/^openai\//u, ""), - api: "openai-responses", - agentRuntime: { id: "pi" }, - input: ["text"], - reasoning: true, - contextWindow: 1_047_576, - maxTokens: 8_192, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }, - ], - }, - }, + providers, }, agents: { defaults: { @@ -155,11 +211,12 @@ describeLive("subagent announce live", () => { it( "lets a parent steer a subagent and receives completion through in-process agent dispatch", async () => { - expect(process.env.OPENAI_API_KEY?.trim(), "OPENAI_API_KEY").toBeTruthy(); + const modelConfig = resolveLiveSubagentModelConfig(); + requireLiveSubagentAuth(modelConfig); const token = `subagent-live-${randomUUID()}`; const port = 30_000 + Math.floor(Math.random() * 10_000); - const modelKey = process.env.OPENCLAW_LIVE_SUBAGENT_E2E_MODEL?.trim() || "openai/gpt-5.5"; + const modelKey = modelConfig.modelKey; const nonce = randomBytes(3).toString("hex").toUpperCase(); const childToken = `CHILD_STEERED_${nonce}`; const parentToken = `PARENT_SAW_${childToken}`; @@ -226,7 +283,7 @@ describeLive("subagent announce live", () => { OPENCLAW_PLUGINS_PATHS: undefined, }, }); - await state.writeConfig(openAiConfig(modelKey, state.workspaceDir, port, token)); + await state.writeConfig(liveSubagentConfig(modelKey, state.workspaceDir, port, token)); clearRuntimeConfigSnapshot(); clearCurrentPluginMetadataSnapshot(); @@ -314,4 +371,146 @@ describeLive("subagent announce live", () => { }, 10 * 60_000, ); + + it( + "runs parallel isolated Gemini subagents with tool-heavy schemas", + async () => { + const modelConfig = resolveLiveSubagentModelConfig(); + if (!modelConfig.modelKey.startsWith("google/")) { + console.warn( + "[subagent-stress] skip: set OPENCLAW_LIVE_SUBAGENT_E2E_MODEL=google/gemini-3.1-pro-preview", + ); + return; + } + requireLiveSubagentAuth(modelConfig); + + const token = `subagent-stress-${randomUUID()}`; + const port = 30_000 + Math.floor(Math.random() * 10_000); + const nonce = randomBytes(3).toString("hex").toUpperCase(); + const sessionKey = `agent:main:live-subagent-stress-${nonce.toLowerCase()}`; + const childTokens = [1, 2, 3].map((index) => `GEMINI_STRESS_${nonce}_${index}`); + const parentToken = `GEMINI_STRESS_PARENT_${nonce}`; + + state = await createOpenClawTestState({ + label: "subagent-gemini-stress-live", + layout: "split", + env: { + OPENCLAW_SKIP_CHANNELS: "1", + OPENCLAW_SKIP_CRON: "1", + OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1", + OPENCLAW_SKIP_CANVAS_HOST: "1", + OPENCLAW_TEST_MINIMAL_GATEWAY: "1", + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1", + OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"), + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + OPENCLAW_PLUGIN_CATALOG_PATHS: undefined, + OPENCLAW_PLUGINS_PATHS: undefined, + OPENCLAW_DEBUG_MODEL_TRANSPORT: "1", + OPENCLAW_DEBUG_MODEL_PAYLOAD: "tools", + OPENCLAW_DEBUG_SSE: "events", + }, + }); + await fs.writeFile( + path.join(state.workspaceDir, "package.json"), + `${JSON.stringify({ name: "openclaw-gemini-stress-live", private: true }, null, 2)}\n`, + "utf8", + ); + await fs.writeFile( + path.join(state.workspaceDir, "AGENTS.md"), + "OpenClaw live stress test workspace. Keep responses concise.\n", + "utf8", + ); + await state.writeConfig( + liveSubagentConfig(modelConfig.modelKey, state.workspaceDir, port, token, { + toolAllow: [ + "sessions_spawn", + "sessions_yield", + "subagents", + "bash", + "read", + "web_search", + "memory_search", + ], + }), + ); + clearRuntimeConfigSnapshot(); + clearCurrentPluginMetadataSnapshot(); + + server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + client = await createGatewayClient({ port, token }); + + let initialError: unknown; + const initialRequest = client.request( + "agent", + { + sessionKey, + idempotencyKey: `live-subagent-stress-${randomUUID()}`, + deliver: false, + timeout: 420, + message: [ + "Run this exact OpenClaw Gemini subagent stress scenario. Use tool calls, not prose.", + `Use nonce ${nonce}.`, + "Spawn all three children before waiting for any child result.", + ...childTokens.map((childToken, index) => { + const childNumber = index + 1; + return `Call sessions_spawn for child ${childNumber} with exactly this JSON input: ${JSON.stringify( + { + task: [ + `You are stress child ${childNumber}.`, + "Use available tools for a tiny multi-tool check.", + "First read package.json if the read tool is available.", + "Then run a tiny shell command if the bash tool is available: printf openclaw.", + "If web_search or memory_search is available, use at most one small query.", + `After the tool work, reply exactly ${childToken}.`, + ].join(" "), + taskName: `gemini_stress_${childNumber}`, + cleanup: "keep", + context: "isolated", + runTimeoutSeconds: 300, + }, + )}.`; + }), + `After the three spawn calls are accepted, call sessions_yield with message="waiting for ${childTokens.join( + ",", + )}" and wait for all child completion events.`, + `Reply exactly ${parentToken} only after all three child tokens are visible.`, + ].join("\n"), + }, + { expectFinal: true, timeoutMs: REQUEST_TIMEOUT_MS }, + ); + initialRequest.catch((error: unknown) => { + initialError = error; + }); + + const completedRuns = await waitFor("three Gemini stress child completions", () => { + if (initialError) { + throw initialError; + } + const runs = listSubagentRunsForRequester(sessionKey).filter((run) => + run.taskName?.startsWith("gemini_stress_"), + ); + const completed = childTokens.every((childToken) => + runs.some( + (run) => + run.frozenResultText?.includes(childToken) === true && run.outcome?.status === "ok", + ), + ); + return completed ? runs : undefined; + }); + + expect(completedRuns).toHaveLength(3); + for (const childToken of childTokens) { + expect(completedRuns.some((run) => run.frozenResultText?.includes(childToken))).toBe(true); + } + + const parent = await initialRequest; + expect(extractPayloadText(parent.result)).toContain(parentToken); + }, + 12 * 60_000, + ); });