diff --git a/extensions/codex/src/app-server/attempt-context.test.ts b/extensions/codex/src/app-server/attempt-context.test.ts index a9bac1be40cf..864c533b653d 100644 --- a/extensions/codex/src/app-server/attempt-context.test.ts +++ b/extensions/codex/src/app-server/attempt-context.test.ts @@ -18,6 +18,7 @@ describe("Codex app-server attempt context", () => { it("returns a run context report without deferred Codex dynamic tool schemas", () => { const tools = [ { + type: "function", name: "message", description: "Send a message.", inputSchema: { @@ -28,15 +29,23 @@ describe("Codex app-server attempt context", () => { }, }, { - name: "web_search", - description: "Search the web.", - inputSchema: { - type: "object", - properties: { - query: { type: "string" }, + type: "namespace", + name: "openclaw", + description: "", + tools: [ + { + type: "function", + name: "web_search", + description: "Search the web.", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + }, + deferLoading: true, }, - }, - deferLoading: true, + ], }, ] as CodexDynamicToolSpec[]; diff --git a/extensions/codex/src/app-server/attempt-context.ts b/extensions/codex/src/app-server/attempt-context.ts index e5bbbacc7db6..9a5ad96cdbdb 100644 --- a/extensions/codex/src/app-server/attempt-context.ts +++ b/extensions/codex/src/app-server/attempt-context.ts @@ -17,7 +17,8 @@ import { import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime"; import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core"; import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints"; -import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js"; +import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js"; +import { flattenCodexDynamicToolFunctions } from "./protocol.js"; import { isJsonObject } from "./protocol.js"; import type { CodexAppServerThreadBinding } from "./session-binding.js"; import { readCodexMirroredSessionHistoryMessages } from "./session-history.js"; @@ -280,7 +281,7 @@ export function buildCodexSystemPromptReport(params: { skillsPrompt: string; tools: CodexDynamicToolSpec[]; }): CodexSystemPromptReport { - const toolEntries = params.tools.map(buildCodexToolReportEntry); + const toolEntries = flattenCodexDynamicToolFunctions(params.tools).map(buildCodexToolReportEntry); const schemaChars = toolEntries.reduce((sum, tool) => sum + tool.schemaChars, 0); const skillsPrompt = params.skillsPrompt.trim(); const bootstrapMaxChars = readPositiveNumber( @@ -344,7 +345,7 @@ function buildCodexSkillReportEntries( .filter((entry) => entry.blockChars > 0); } -function buildCodexToolReportEntry(tool: CodexDynamicToolSpec): CodexToolReportEntry { +function buildCodexToolReportEntry(tool: CodexDynamicToolFunctionSpec): CodexToolReportEntry { const summary = tool.description.trim(); if (tool.deferLoading === true) { return { @@ -854,13 +855,15 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string } /** Returns whether the current dynamic tool list can serve workspace memory. */ -export function hasCodexWorkspaceMemoryTools(tools: readonly { name: string }[]): boolean { +export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean { return getCodexWorkspaceMemoryToolNames(tools).length > 0; } /** Lists available memory tool names understood by Codex workspace memory routing. */ -export function getCodexWorkspaceMemoryToolNames(tools: readonly { name: string }[]): string[] { - const availableToolNames = new Set(tools.map((tool) => normalizeCodexDynamicToolName(tool.name))); +export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] { + const availableToolNames = new Set( + flattenCodexDynamicToolFunctions(tools).map((tool) => normalizeCodexDynamicToolName(tool.name)), + ); return Array.from(CODEX_MEMORY_TOOL_NAMES).filter((name) => availableToolNames.has(name)); } diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index 3e1146034f4a..5b01aa13fc22 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -29,6 +29,7 @@ import { shouldUseDirectCodexDynamicToolsForModel, } from "./dynamic-tool-profile.js"; import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; +import { flattenCodexDynamicToolFunctions } from "./protocol.js"; import { createCodexTestModel } from "./test-support.js"; let tempDir: string; @@ -401,7 +402,9 @@ describe("Codex app-server dynamic tool build", () => { expect(shouldUseDirectCodexDynamicToolsForModel("gpt-5.4-nano")).toBe(true); expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.4-nano")).toBe("direct"); expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.5")).toBe("searchable"); - const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search"); + const webSearch = flattenCodexDynamicToolFunctions(toolBridge.specs).find( + (tool) => tool.name === "web_search", + ); expect(webSearch).not.toHaveProperty("deferLoading"); expect(webSearch).not.toHaveProperty("namespace"); }); diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index c1ea03e138be..22f7b5ec0016 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -27,7 +27,7 @@ import { CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE, createCodexDynamicToolBridge, } from "./dynamic-tools.js"; -import type { JsonValue } from "./protocol.js"; +import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js"; function createTool(overrides: Partial): AnyAgentTool { return { @@ -115,6 +115,20 @@ function expectDynamicSpec( } } +function flattenSpecsWithNamespace( + specs: readonly CodexDynamicToolSpec[], +): Array { + return specs.flatMap((spec) => + spec.type === "namespace" + ? spec.tools.map((tool) => ({ ...tool, namespace: spec.name })) + : [spec], + ); +} + +function specNames(specs: readonly CodexDynamicToolSpec[]): string[] { + return flattenSpecsWithNamespace(specs).map((tool) => tool.name); +} + function expectNoNamespace(spec: unknown) { const record = requireRecord(spec, "tool spec"); expect(record).not.toHaveProperty("namespace"); @@ -176,11 +190,12 @@ describe("createCodexDynamicToolBridge", () => { signal: new AbortController().signal, }); - const webSearch = bridge.specs.find((tool) => tool.name === "web_search"); - const message = bridge.specs.find((tool) => tool.name === "message"); - const heartbeat = bridge.specs.find((tool) => tool.name === HEARTBEAT_RESPONSE_TOOL_NAME); - const sessionsSpawn = bridge.specs.find((tool) => tool.name === "sessions_spawn"); - const sessionsYield = bridge.specs.find((tool) => tool.name === "sessions_yield"); + const specs = flattenSpecsWithNamespace(bridge.specs); + const webSearch = specs.find((tool) => tool.name === "web_search"); + const message = specs.find((tool) => tool.name === "message"); + const heartbeat = specs.find((tool) => tool.name === HEARTBEAT_RESPONSE_TOOL_NAME); + const sessionsSpawn = specs.find((tool) => tool.name === "sessions_spawn"); + const sessionsYield = specs.find((tool) => tool.name === "sessions_yield"); expectDynamicSpec(webSearch, { name: "web_search", @@ -212,14 +227,21 @@ describe("createCodexDynamicToolBridge", () => { directToolNames: ["message"], }); + const specs = flattenSpecsWithNamespace(bridge.specs); expect(bridge.specs).toHaveLength(2); - expectDynamicSpec(bridge.specs[0], { name: "message" }); - expectDynamicSpec(bridge.specs[1], { - name: "web_search", - namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE, - deferLoading: true, - }); - expectNoNamespace(bridge.specs[0]); + expectDynamicSpec( + specs.find((tool) => tool.name === "message"), + { name: "message" }, + ); + expectDynamicSpec( + specs.find((tool) => tool.name === "web_search"), + { + name: "web_search", + namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE, + deferLoading: true, + }, + ); + expectNoNamespace(specs.find((tool) => tool.name === "message")); }); it("can register a durable tool schema while denying execution for the current turn", async () => { @@ -236,11 +258,8 @@ describe("createCodexDynamicToolBridge", () => { hookContext: { runId: "run-unavailable", onToolOutcome }, }); - expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]); - expect(bridge.specs.map((tool) => tool.name)).toEqual([ - "message", - HEARTBEAT_RESPONSE_TOOL_NAME, - ]); + expect(specNames(bridge.availableSpecs)).toEqual(["message"]); + expect(specNames(bridge.specs)).toEqual(["message", HEARTBEAT_RESPONSE_TOOL_NAME]); const result = await bridge.handleToolCall( { @@ -312,11 +331,11 @@ describe("createCodexDynamicToolBridge", () => { signal: new AbortController().signal, }); - expect(bridge.availableSpecs[0]?.inputSchema).toEqual({ + expect(flattenSpecsWithNamespace(bridge.availableSpecs)[0]?.inputSchema).toEqual({ type: "object", properties: { current: { type: "string" } }, }); - expect(bridge.specs[0]?.inputSchema).toEqual({ + expect(flattenSpecsWithNamespace(bridge.specs)[0]?.inputSchema).toEqual({ type: "object", properties: { durable: { type: "string" } }, }); @@ -352,8 +371,8 @@ describe("createCodexDynamicToolBridge", () => { unsubscribeDiagnostics(); } - expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]); - expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]); + expect(specNames(bridge.availableSpecs)).toEqual(["message"]); + expect(specNames(bridge.specs)).toEqual(["message"]); expect(bridge.telemetry.quarantinedTools).toEqual([ { tool: "fuzzplugin_move_angles", @@ -450,8 +469,8 @@ describe("createCodexDynamicToolBridge", () => { signal: new AbortController().signal, }); - expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]); - expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]); + expect(specNames(bridge.availableSpecs)).toEqual(["message"]); + expect(specNames(bridge.specs)).toEqual(["message"]); expect(bridge.telemetry.quarantinedTools).toEqual([ { tool: "tool[0]", @@ -509,8 +528,8 @@ describe("createCodexDynamicToolBridge", () => { signal: new AbortController().signal, }); - expect(registeredBridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]); - expect(registeredBridge.specs.map((tool) => tool.name)).toEqual(["message"]); + expect(specNames(registeredBridge.availableSpecs)).toEqual(["message"]); + expect(specNames(registeredBridge.specs)).toEqual(["message"]); }); it("can expose all dynamic tools directly for compatibility", () => { diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 9eb5d1e8caf5..d85bf769b5f1 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -48,6 +48,7 @@ import type { CodexDynamicToolCallParams, CodexDynamicToolCallResponse, CodexDynamicToolDiagnosticTerminalType, + CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue, } from "./protocol.js"; @@ -201,20 +202,16 @@ export function createCodexDynamicToolBridge(params: { ...(params.directToolNames ?? []), ]); return { - availableSpecs: availableTools.map((entry) => - createCodexDynamicToolSpec({ - entry, - loading: params.loading ?? "searchable", - directToolNames, - }), - ), - specs: registeredSpecTools.map((entry) => - createCodexDynamicToolSpec({ - entry, - loading: params.loading ?? "searchable", - directToolNames, - }), - ), + availableSpecs: createCodexDynamicToolSpecs({ + entries: availableTools, + loading: params.loading ?? "searchable", + directToolNames, + }), + specs: createCodexDynamicToolSpecs({ + entries: registeredSpecTools, + loading: params.loading ?? "searchable", + directToolNames, + }), telemetry, handleToolCall: async (call, options) => { const toolEntry = toolMap.get(call.tool); @@ -502,24 +499,41 @@ function wrapProjectedCodexDynamicTools( return { tools: wrappedTools, quarantinedTools }; } -function createCodexDynamicToolSpec(params: { - entry: ProjectedCodexDynamicTool; +function createCodexDynamicToolSpecs(params: { + entries: readonly ProjectedCodexDynamicTool[]; loading: CodexDynamicToolsLoading; directToolNames: ReadonlySet; -}): CodexDynamicToolSpec { - const base = { +}): CodexDynamicToolSpec[] { + const specs: CodexDynamicToolSpec[] = []; + const namespaceTools: CodexDynamicToolFunctionSpec[] = []; + for (const entry of params.entries) { + const functionSpec = createCodexDynamicToolFunctionSpec({ entry }); + if (params.loading === "direct" || params.directToolNames.has(entry.name)) { + specs.push(functionSpec); + continue; + } + namespaceTools.push({ ...functionSpec, deferLoading: true }); + } + if (namespaceTools.length > 0) { + specs.push({ + type: "namespace", + name: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE, + description: "", + tools: namespaceTools, + }); + } + return specs; +} + +function createCodexDynamicToolFunctionSpec(params: { + entry: ProjectedCodexDynamicTool; +}): CodexDynamicToolFunctionSpec { + return { + type: "function", name: params.entry.name, description: params.entry.description, inputSchema: params.entry.inputSchema, }; - if (params.loading === "direct" || params.directToolNames.has(params.entry.name)) { - return base; - } - return { - ...base, - namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE, - deferLoading: true, - }; } function projectCodexDynamicTools(tools: readonly AnyAgentTool[]): { diff --git a/extensions/codex/src/app-server/protocol-generated/json/v2/GetAccountResponse.json b/extensions/codex/src/app-server/protocol-generated/json/v2/GetAccountResponse.json index d8b407c2a1c3..f0ab3e4acf92 100644 --- a/extensions/codex/src/app-server/protocol-generated/json/v2/GetAccountResponse.json +++ b/extensions/codex/src/app-server/protocol-generated/json/v2/GetAccountResponse.json @@ -45,6 +45,14 @@ }, { "properties": { + "credentialSource": { + "allOf": [ + { + "$ref": "#/definitions/AmazonBedrockCredentialSource" + } + ], + "default": "awsManaged" + }, "type": { "enum": [ "amazonBedrock" @@ -61,6 +69,13 @@ } ] }, + "AmazonBedrockCredentialSource": { + "enum": [ + "codexManaged", + "awsManaged" + ], + "type": "string" + }, "PlanType": { "enum": [ "free", diff --git a/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json b/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json index acccb22e785a..9045845f586b 100644 --- a/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json +++ b/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json @@ -861,6 +861,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1047,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1617,6 +1633,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { @@ -1675,6 +1723,32 @@ "title": "ImageViewThreadItem", "type": "object" }, + { + "properties": { + "durationMs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType", + "type": "string" + } + }, + "required": [ + "durationMs", + "id", + "type" + ], + "title": "SleepThreadItem", + "type": "object" + }, { "properties": { "id": { @@ -1790,11 +1864,6 @@ ] }, "ThreadSource": { - "enum": [ - "user", - "subagent", - "memory_consolidation" - ], "type": "string" }, "ThreadStatus": { diff --git a/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadStartResponse.json b/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadStartResponse.json index 9b88689fb729..c9c33e38f7ff 100644 --- a/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadStartResponse.json +++ b/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadStartResponse.json @@ -861,6 +861,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1047,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1617,6 +1633,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { @@ -1675,6 +1723,32 @@ "title": "ImageViewThreadItem", "type": "object" }, + { + "properties": { + "durationMs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType", + "type": "string" + } + }, + "required": [ + "durationMs", + "id", + "type" + ], + "title": "SleepThreadItem", + "type": "object" + }, { "properties": { "id": { @@ -1790,11 +1864,6 @@ ] }, "ThreadSource": { - "enum": [ - "user", - "subagent", - "memory_consolidation" - ], "type": "string" }, "ThreadStatus": { diff --git a/extensions/codex/src/app-server/protocol-generated/json/v2/TurnCompletedNotification.json b/extensions/codex/src/app-server/protocol-generated/json/v2/TurnCompletedNotification.json index 968e4c6b489d..e33b2e3e2d43 100644 --- a/extensions/codex/src/app-server/protocol-generated/json/v2/TurnCompletedNotification.json +++ b/extensions/codex/src/app-server/protocol-generated/json/v2/TurnCompletedNotification.json @@ -610,6 +610,14 @@ "minLength": 1, "type": "string" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -1133,6 +1141,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { @@ -1191,6 +1231,32 @@ "title": "ImageViewThreadItem", "type": "object" }, + { + "properties": { + "durationMs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType", + "type": "string" + } + }, + "required": [ + "durationMs", + "id", + "type" + ], + "title": "SleepThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/extensions/codex/src/app-server/protocol-generated/json/v2/TurnStartResponse.json b/extensions/codex/src/app-server/protocol-generated/json/v2/TurnStartResponse.json index 4518da5a89d0..5db2fd79730b 100644 --- a/extensions/codex/src/app-server/protocol-generated/json/v2/TurnStartResponse.json +++ b/extensions/codex/src/app-server/protocol-generated/json/v2/TurnStartResponse.json @@ -610,6 +610,14 @@ "minLength": 1, "type": "string" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -1133,6 +1141,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { @@ -1191,6 +1231,32 @@ "title": "ImageViewThreadItem", "type": "object" }, + { + "properties": { + "durationMs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType", + "type": "string" + } + }, + "required": [ + "durationMs", + "id", + "type" + ], + "title": "SleepThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index 482a4fb75c85..9f9baf0bf1f1 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -65,12 +65,31 @@ export type CodexUserInput = path: string; }; -export type CodexDynamicToolSpec = JsonObject & { +export type CodexDynamicToolFunctionSpec = JsonObject & { + type: "function"; name: string; description: string; inputSchema: JsonValue; + deferLoading?: boolean; }; +export type CodexDynamicToolNamespaceTool = CodexDynamicToolFunctionSpec; + +export type CodexDynamicToolNamespaceSpec = JsonObject & { + type: "namespace"; + name: string; + description: string; + tools: CodexDynamicToolNamespaceTool[]; +}; + +export type CodexDynamicToolSpec = CodexDynamicToolFunctionSpec | CodexDynamicToolNamespaceSpec; + +export function flattenCodexDynamicToolFunctions( + tools: readonly CodexDynamicToolSpec[] | undefined, +): CodexDynamicToolFunctionSpec[] { + return (tools ?? []).flatMap((tool) => (tool.type === "namespace" ? tool.tools : [tool])); +} + export type CodexTurnEnvironmentParams = JsonObject & { environmentId: string; cwd: string; diff --git a/extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts b/extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts index b735efb83514..bea10aa92542 100644 --- a/extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts @@ -23,7 +23,11 @@ import { emitDynamicToolTerminalDiagnostic, } from "./dynamic-tool-diagnostics.js"; import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; -import type { CodexDynamicToolCallParams } from "./protocol.js"; +import { + flattenCodexDynamicToolFunctions, + type CodexDynamicToolCallParams, + type CodexDynamicToolSpec, +} from "./protocol.js"; import { createParams, createCodexRuntimePlanFixture, @@ -39,6 +43,10 @@ function flushDiagnosticEvents() { return waitForDiagnosticEventsDrained(); } +function specNames(specs: readonly CodexDynamicToolSpec[]): string[] { + return flattenCodexDynamicToolFunctions(specs).map((tool) => tool.name); +} + function activeDiagnosticToolKeys(events: DiagnosticEventPayload[]): Set { const active = new Set(); for (const event of events) { @@ -366,7 +374,7 @@ describe("runCodexAppServerAttempt dynamic tools", () => { "features.code_mode_only"?: boolean; mcp_servers?: Record; }; - dynamicTools?: Array<{ name: string }>; + dynamicTools?: CodexDynamicToolSpec[]; environments?: unknown[]; } | undefined; @@ -382,7 +390,7 @@ describe("runCodexAppServerAttempt dynamic tools", () => { }, }); expect(startParams?.environments).toBeUndefined(); - expect(startParams?.dynamicTools?.map((tool) => tool.name)).toEqual([ + expect(specNames(startParams?.dynamicTools ?? [])).toEqual([ "message", "node_exec", "node_process", diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index df3c219abbef..c77c3b58ed85 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -41,7 +41,12 @@ import { } from "./event-projector.js"; import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js"; import { buildCodexPluginThreadConfig } from "./plugin-thread-config.js"; -import type { CodexServerNotification } from "./protocol.js"; +import { + flattenCodexDynamicToolFunctions, + type CodexDynamicToolFunctionSpec, + type CodexDynamicToolSpec, + type CodexServerNotification, +} from "./protocol.js"; import { assistantMessage, createAppServerHarness, @@ -149,6 +154,7 @@ function createMessageDynamicTool( actions: string[] = ["send"], ): Parameters[0]["dynamicTools"][number] { return { + type: "function", name: "message", description, inputSchema: { @@ -169,6 +175,7 @@ function createNamedDynamicTool( name: string, ): Parameters[0]["dynamicTools"][number] { return { + type: "function", name, description: `${name} test tool`, inputSchema: { @@ -382,6 +389,20 @@ type RuntimeDynamicToolForTest = Parameters< typeof createCodexDynamicToolBridge >[0]["tools"][number]; +function flattenSpecsWithNamespace( + specs: readonly CodexDynamicToolSpec[], +): Array { + return specs.flatMap((spec) => + spec.type === "namespace" + ? spec.tools.map((tool) => ({ ...tool, namespace: spec.name })) + : [spec], + ); +} + +function specNames(specs: readonly CodexDynamicToolSpec[]): string[] { + return flattenCodexDynamicToolFunctions(specs).map((tool) => tool.name); +} + function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest { return { name, @@ -506,11 +527,11 @@ describe("runCodexAppServerAttempt", () => { const startRequest = request.mock.calls.find(([method]) => method === "thread/start"); const startParams = startRequest?.[1] as Record | undefined; const startConfig = startParams?.config as Record | undefined; - const startDynamicTools = startParams?.dynamicTools as Array<{ name: string }> | undefined; + const startDynamicTools = startParams?.dynamicTools as CodexDynamicToolSpec[] | undefined; expect(startConfig?.["features.code_mode"]).toBe(false); expect(startConfig?.["features.code_mode_only"]).toBe(false); expect(startParams?.environments).toEqual([]); - expect(startDynamicTools?.map((tool) => tool.name)).toEqual([ + expect(specNames(startDynamicTools ?? [])).toEqual([ "message", "sandbox_exec", "sandbox_process", @@ -631,7 +652,7 @@ describe("runCodexAppServerAttempt", () => { const startParams = startRequest?.[1] as | { cwd?: string; - dynamicTools?: Array<{ name: string }>; + dynamicTools?: CodexDynamicToolSpec[]; environments?: Array<{ environmentId?: string; cwd?: string }>; sandbox?: string; config?: { @@ -649,7 +670,7 @@ describe("runCodexAppServerAttempt", () => { expect(startParams?.config?.["features.code_mode"]).toBe(true); expect(startParams?.config?.["features.code_mode_only"]).toBe(false); expect(startParams?.config?.["features.apply_patch_streaming_events"]).toBe(true); - expect(startParams?.dynamicTools?.map((tool) => tool.name)).toEqual(["message"]); + expect(specNames(startParams?.dynamicTools ?? [])).toEqual(["message"]); expect(startParams?.environments).toEqual([ { environmentId: environmentAddParams?.environmentId, cwd: "/workspace" }, ]); @@ -902,10 +923,10 @@ describe("runCodexAppServerAttempt", () => { }); const startRequest = request.mock.calls.find(([method]) => method === "thread/start"); - const dynamicToolNames = ( - (startRequest?.[1] as { dynamicTools?: Array<{ name: string }> } | undefined)?.dynamicTools ?? - [] - ).map((tool) => tool.name); + const dynamicToolNames = specNames( + (startRequest?.[1] as { dynamicTools?: CodexDynamicToolSpec[] } | undefined)?.dynamicTools ?? + [], + ); expect(dynamicToolNames).toContain("message"); expect(dynamicToolNames).toContain("web_search"); @@ -1572,11 +1593,12 @@ describe("runCodexAppServerAttempt", () => { directToolNames: ["message"], }); - const message = toolBridge.specs.find((tool) => tool.name === "message"); - const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search"); - const heartbeat = toolBridge.specs.find((tool) => tool.name === "heartbeat_respond"); - const sessionsSpawn = toolBridge.specs.find((tool) => tool.name === "sessions_spawn"); - const sessionsYield = toolBridge.specs.find((tool) => tool.name === "sessions_yield"); + const specs = flattenSpecsWithNamespace(toolBridge.specs); + const message = specs.find((tool) => tool.name === "message"); + const webSearch = specs.find((tool) => tool.name === "web_search"); + const heartbeat = specs.find((tool) => tool.name === "heartbeat_respond"); + const sessionsSpawn = specs.find((tool) => tool.name === "sessions_spawn"); + const sessionsYield = specs.find((tool) => tool.name === "sessions_yield"); expect(message).not.toHaveProperty("namespace"); expect(message).not.toHaveProperty("deferLoading"); @@ -1624,7 +1646,7 @@ describe("runCodexAppServerAttempt", () => { const normalInstructions = testing.buildDeveloperInstructions(createRunParams(), { dynamicTools: normalBridge.availableSpecs, }); - const registeredToolNames = normalBridge.specs.map((tool) => tool.name); + const registeredToolNames = specNames(normalBridge.specs); expect(registeredToolNames).toContain("message"); expect(registeredToolNames).toContain("heartbeat_respond"); @@ -1646,8 +1668,8 @@ describe("runCodexAppServerAttempt", () => { registeredTools, ); - expect(heartbeatBridge.specs.map((tool) => tool.name)).toEqual(registeredToolNames); - expect(nextNormalBridge.specs.map((tool) => tool.name)).toEqual(registeredToolNames); + expect(specNames(heartbeatBridge.specs)).toEqual(registeredToolNames); + expect(specNames(nextNormalBridge.specs)).toEqual(registeredToolNames); }); it("keeps the persistent dynamic schema stable across heartbeat-only turns", async () => { @@ -1700,13 +1722,9 @@ describe("runCodexAppServerAttempt", () => { registeredTools, ); - expect(heartbeatBridge.availableSpecs.map((tool) => tool.name)).toEqual(["heartbeat_respond"]); - expect(heartbeatBridge.specs.map((tool) => tool.name)).toEqual( - normalBridge.specs.map((tool) => tool.name), - ); - expect(nextNormalBridge.specs.map((tool) => tool.name)).toEqual( - normalBridge.specs.map((tool) => tool.name), - ); + expect(specNames(heartbeatBridge.availableSpecs)).toEqual(["heartbeat_respond"]); + expect(specNames(heartbeatBridge.specs)).toEqual(specNames(normalBridge.specs)); + expect(specNames(nextNormalBridge.specs)).toEqual(specNames(normalBridge.specs)); }); it("disables Codex native tool surfaces when runtime toolsAllow is empty", async () => { @@ -1745,7 +1763,7 @@ describe("runCodexAppServerAttempt", () => { const startRequest = request.mock.calls.find(([method]) => method === "thread/start"); const startParams = startRequest?.[1] as | { - dynamicTools?: Array<{ name?: string }>; + dynamicTools?: CodexDynamicToolSpec[]; environments?: unknown[]; developerInstructions?: string; config?: { @@ -5307,9 +5325,9 @@ describe("runCodexAppServerAttempt", () => { const startRequest = requests.find((request) => request.method === "thread/start"); const startRequestParams = startRequest?.params as Record | undefined; const startConfig = startRequestParams?.config as Record | undefined; - const dynamicToolNames = ( - startRequestParams?.dynamicTools as Array<{ name?: string }> | undefined - )?.map((tool) => tool.name); + const dynamicToolNames = specNames( + (startRequestParams?.dynamicTools as CodexDynamicToolSpec[] | undefined) ?? [], + ); expect(startRequestParams?.model).toBe("local-model"); expect(startRequestParams?.modelProvider).toBe("lmstudio"); expect(startConfig?.web_search).toBe("disabled"); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 866121c791a7..87d25fe4afd1 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -206,6 +206,7 @@ import { readCodexDynamicToolCallParams, } from "./protocol-validators.js"; import { + flattenCodexDynamicToolFunctions, isJsonObject, type CodexSandboxPolicy, type CodexTurnEnvironmentParams, @@ -920,7 +921,9 @@ export async function runCodexAppServerAttempt( messages: historyMessages, tokenBudget: params.contextTokenBudget, availableTools: new Set( - toolBridge.availableSpecs.map((tool) => tool.name).filter(isNonEmptyString), + flattenCodexDynamicToolFunctions(toolBridge.availableSpecs) + .map((tool) => tool.name) + .filter(isNonEmptyString), ), citationsMode: params.config?.memory?.citations, modelId: params.modelId, @@ -1355,7 +1358,7 @@ export async function runCodexAppServerAttempt( threadId: thread.threadId, authProfileId: startupAuthProfileId, workspaceDir: effectiveWorkspace, - toolCount: toolBridge.specs.length, + toolCount: flattenCodexDynamicToolFunctions(toolBridge.specs).length, }); recordCodexTrajectoryContext(trajectoryRecorder, { attempt: params, diff --git a/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts b/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts index 36da7053772a..ef3cf1561750 100644 --- a/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts @@ -102,6 +102,7 @@ describe("Codex app-server dynamic tool schema boundary contract", () => { const workspaceDir = path.join(tempDir, "workspace"); const parameterFreeTool = createParameterFreeTool("message"); const dynamicTool = { + type: "function" as const, name: parameterFreeTool.name, description: parameterFreeTool.description, inputSchema: normalizedParameterFreeSchema(), @@ -180,6 +181,7 @@ describe("Codex app-server dynamic tool schema boundary contract", () => { cwd: workspaceDir, dynamicTools: [ { + type: "function", name: "message", description: "Permissive test tool", inputSchema: { type: "object" }, @@ -194,6 +196,7 @@ describe("Codex app-server dynamic tool schema boundary contract", () => { cwd: workspaceDir, dynamicTools: [ { + type: "function", name: permissiveTool.name, description: permissiveTool.description, inputSchema: permissiveTool.parameters, diff --git a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts index 73d4d1ee6fe8..af1a586afb18 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts @@ -1,6 +1,7 @@ // Codex tests cover thread lifecycle.binding plugin behavior. import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { CodexDynamicToolFunctionSpec } from "./protocol.js"; import { createParams as createRunAttemptParams, setupRunAttemptTestHooks, @@ -66,8 +67,9 @@ function writeCodexAppServerBinding(...args: Parameters[0]["dynamicTools"][number] { +): CodexDynamicToolFunctionSpec { return { + type: "function", name: "message", description, inputSchema: { @@ -84,10 +86,9 @@ function createMessageDynamicTool( }; } -function createNamedDynamicTool( - name: string, -): Parameters[0]["dynamicTools"][number] { +function createNamedDynamicTool(name: string): CodexDynamicToolFunctionSpec { return { + type: "function", name, description: `${name} test tool`, inputSchema: { @@ -102,9 +103,10 @@ function createDeferredNamedDynamicTool( name: string, ): Parameters[0]["dynamicTools"][number] { return { - ...createNamedDynamicTool(name), - namespace: "openclaw", - deferLoading: true, + type: "namespace", + name: "openclaw", + description: "", + tools: [{ ...createNamedDynamicTool(name), deferLoading: true }], }; } diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index a9c3a4773f8a..0245e1286510 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -196,23 +196,31 @@ describe("Codex app-server native code mode config", () => { const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" }), { dynamicTools: [ { + type: "function", name: "message", description: "Send a message", inputSchema: { type: "object" }, }, { - name: "music_generate", - description: "Create music", - inputSchema: { type: "object" }, - namespace: "openclaw", - deferLoading: true, - }, - { - name: "image_generate", - description: "Create images", - inputSchema: { type: "object" }, - namespace: "openclaw", - deferLoading: true, + type: "namespace", + name: "openclaw", + description: "", + tools: [ + { + type: "function", + name: "music_generate", + description: "Create music", + inputSchema: { type: "object" }, + deferLoading: true, + }, + { + type: "function", + name: "image_generate", + description: "Create images", + inputSchema: { type: "object" }, + deferLoading: true, + }, + ], }, ], }); @@ -228,11 +236,18 @@ describe("Codex app-server native code mode config", () => { const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" }), { dynamicTools: [ { - name: "skill_workshop", - description: "Manage skill proposals", - inputSchema: { type: "object" }, - namespace: "openclaw", - deferLoading: true, + type: "namespace", + name: "openclaw", + description: "", + tools: [ + { + type: "function", + name: "skill_workshop", + description: "Manage skill proposals", + inputSchema: { type: "object" }, + deferLoading: true, + }, + ], }, ], }); @@ -250,6 +265,7 @@ describe("Codex app-server native code mode config", () => { const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" }), { dynamicTools: [ { + type: "function", name: "message", description: "Send a message", inputSchema: { type: "object" }, @@ -271,6 +287,7 @@ describe("Codex app-server native code mode config", () => { }; const directFingerprint = codexDynamicToolsFingerprint([ { + type: "function", name: "message", description: "Send a visible message", inputSchema, @@ -278,11 +295,18 @@ describe("Codex app-server native code mode config", () => { ]); const searchableFingerprint = codexDynamicToolsFingerprint([ { - name: "message", - description: "Load and send a visible message", - inputSchema, - namespace: "openclaw", - deferLoading: true, + type: "namespace", + name: "openclaw", + description: "", + tools: [ + { + type: "function", + name: "message", + description: "Load and send a visible message", + inputSchema, + deferLoading: true, + }, + ], }, ]); diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 5aed00beeee3..302053a10a94 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -37,6 +37,7 @@ import { assertCodexThreadStartResponse, } from "./protocol-validators.js"; import { + flattenCodexDynamicToolFunctions, isJsonObject, type CodexDynamicToolSpec, type CodexSandboxPolicy, @@ -322,7 +323,7 @@ export async function startOrResumeThread(params: { const dynamicToolsFingerprint = lifecycleTiming.measureSync("dynamic-tools-fingerprint", () => fingerprintDynamicTools(params.dynamicTools), ); - const dynamicToolsContainDeferred = params.dynamicTools.some( + const dynamicToolsContainDeferred = flattenCodexDynamicToolFunctions(params.dynamicTools).some( (tool) => tool.deferLoading === true, ); const webSearchPlan = lifecycleTiming.measureSync("web-search-plan", () => @@ -1489,17 +1490,25 @@ function fingerprintEnvironmentSelection( } function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue { - if (!isJsonObject(tool)) { - return stabilizeJsonValue(tool); + return stabilizeDynamicToolFingerprintValue(tool); +} + +function stabilizeDynamicToolFingerprintValue(value: JsonValue): JsonValue { + if (Array.isArray(value)) { + return value.map(stabilizeDynamicToolFingerprintValue); } + if (!isJsonObject(value)) { + return value; + } + const stable: JsonObject = {}; - for (const [key, child] of Object.entries(tool).toSorted(([left], [right]) => + for (const [key, child] of Object.entries(value).toSorted(([left], [right]) => left.localeCompare(right), )) { if (key === "description") { continue; } - stable[key] = stabilizeJsonValue(child); + stable[key] = stabilizeDynamicToolFingerprintValue(child); } return stable; } @@ -1574,7 +1583,7 @@ function buildDeferredDynamicToolManifest( ): string | undefined { const deferredToolNames = [ ...new Set( - (dynamicTools ?? []) + flattenCodexDynamicToolFunctions(dynamicTools) .filter((tool) => tool.deferLoading === true) .map((tool) => tool.name.trim()) .filter(Boolean), @@ -1589,7 +1598,7 @@ function buildDeferredDynamicToolManifest( function buildSkillWorkshopInstruction( dynamicTools: readonly CodexDynamicToolSpec[] | undefined, ): string | undefined { - const hasSkillWorkshop = (dynamicTools ?? []).some( + const hasSkillWorkshop = flattenCodexDynamicToolFunctions(dynamicTools).some( (tool) => tool.name.trim() === SKILL_WORKSHOP_TOOL_NAME, ); if (!hasSkillWorkshop) { @@ -1603,7 +1612,7 @@ function buildVisibleReplyInstruction( dynamicTools: readonly CodexDynamicToolSpec[] | undefined, ): string { const messageToolAvailable = dynamicTools - ? dynamicTools.some((tool) => tool.name.trim() === "message") + ? flattenCodexDynamicToolFunctions(dynamicTools).some((tool) => tool.name.trim() === "message") : params.disableMessageTool !== true; if (params.sourceReplyDeliveryMode === "message_tool_only" && messageToolAvailable) { return "Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. Do not repeat that visible content in your final answer."; diff --git a/extensions/codex/src/app-server/trajectory.test.ts b/extensions/codex/src/app-server/trajectory.test.ts index b9aeff692562..4eb99b9a6e09 100644 --- a/extensions/codex/src/app-server/trajectory.test.ts +++ b/extensions/codex/src/app-server/trajectory.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createCodexTrajectoryRecorder, + recordCodexTrajectoryContext, resolveCodexTrajectoryAppendFlags, resolveCodexTrajectoryPointerFlags, } from "./trajectory.js"; @@ -120,6 +121,55 @@ describe("Codex trajectory recorder", () => { expect(parsed.modelId).toBe("gpt-5.5"); }); + it("records namespace dynamic tools as callable trajectory tool definitions", async () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const init = { + cwd: tmpDir, + attempt: { + sessionFile, + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + provider: "codex", + modelId: "gpt-5.4", + model: { api: "responses" }, + } as never, + env: {}, + tools: [ + { + type: "namespace", + name: "openclaw", + description: "", + tools: [ + { + type: "function", + name: "web_search", + description: "Search the web.", + inputSchema: { type: "object" }, + deferLoading: true, + }, + ], + }, + ], + } satisfies Parameters[0]; + const recorder = createCodexTrajectoryRecorder(init); + + recordCodexTrajectoryContext(expectTrajectoryRecorder(recorder), init); + await recorder?.flush(); + + const parsed = JSON.parse( + fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"), + ); + expect(parsed.data?.tools).toEqual([ + { + name: "web_search", + description: "Search the web.", + parameters: { type: "object" }, + }, + ]); + }); + it("sanitizes session ids when resolving an override directory", async () => { const tmpDir = makeTempDir(); const recorder = createCodexTrajectoryRecorder({ diff --git a/extensions/codex/src/app-server/trajectory.ts b/extensions/codex/src/app-server/trajectory.ts index db450828f5f5..4130e900db40 100644 --- a/extensions/codex/src/app-server/trajectory.ts +++ b/extensions/codex/src/app-server/trajectory.ts @@ -15,6 +15,7 @@ import { resolveRegularFileAppendFlags, } from "openclaw/plugin-sdk/security-runtime"; import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js"; +import { flattenCodexDynamicToolFunctions, type CodexDynamicToolSpec } from "./protocol.js"; /** Runtime trajectory recorder used by Codex run attempts and event projectors. */ export type CodexTrajectoryRecorder = { @@ -28,7 +29,7 @@ type CodexTrajectoryInit = { cwd: string; developerInstructions?: string; prompt?: string; - tools?: Array<{ name?: string; description?: string; inputSchema?: unknown }>; + tools?: CodexDynamicToolSpec[]; env?: NodeJS.ProcessEnv; }; @@ -298,12 +299,12 @@ function resolveContainedPath(baseDir: string, fileName: string): string { } function toTrajectoryToolDefinitions( - tools: Array<{ name?: string; description?: string; inputSchema?: unknown }> | undefined, + tools: readonly CodexDynamicToolSpec[] | undefined, ): Array<{ name: string; description?: string; parameters?: unknown }> | undefined { if (!tools || tools.length === 0) { return undefined; } - return tools + return flattenCodexDynamicToolFunctions(tools) .flatMap((tool) => { const name = tool.name?.trim(); if (!name) { diff --git a/scripts/check-codex-app-server-protocol.ts b/scripts/check-codex-app-server-protocol.ts index d0fa520c6778..ef256abaab43 100644 --- a/scripts/check-codex-app-server-protocol.ts +++ b/scripts/check-codex-app-server-protocol.ts @@ -33,8 +33,21 @@ const checks: Array<{ file: string; snippets: string[] }> = [ }, { file: "v2/DynamicToolSpec.ts", + snippets: [ + '"function"', + "& DynamicToolFunctionSpec", + '"namespace"', + "& DynamicToolNamespaceSpec", + ], + }, + { + file: "v2/DynamicToolFunctionSpec.ts", snippets: ["name: string", "description: string", "inputSchema: JsonValue"], }, + { + file: "v2/DynamicToolNamespaceSpec.ts", + snippets: ["name: string", "description: string", "tools: Array"], + }, { file: "v2/CommandExecutionApprovalDecision.ts", snippets: ['"accept"', '"acceptForSession"', '"decline"', '"cancel"'], diff --git a/test/helpers/agents/happy-path-prompt-snapshots.ts b/test/helpers/agents/happy-path-prompt-snapshots.ts index f9c496f910a7..318588fe603e 100644 --- a/test/helpers/agents/happy-path-prompt-snapshots.ts +++ b/test/helpers/agents/happy-path-prompt-snapshots.ts @@ -69,12 +69,27 @@ const HAPPY_PATH_TOOL_NAMES = new Set([ "web_fetch", ]); -type CodexDynamicToolSpec = { +type CodexDynamicToolFunctionSpec = { + type?: "function"; name: string; description?: string; inputSchema?: unknown; }; +type CodexDynamicToolNamespaceSpec = { + type: "namespace"; + name: string; + tools: CodexDynamicToolFunctionSpec[]; +}; + +type CodexDynamicToolSpec = CodexDynamicToolFunctionSpec | CodexDynamicToolNamespaceSpec; + +function flattenCodexDynamicToolSpecs( + specs: readonly CodexDynamicToolSpec[], +): CodexDynamicToolFunctionSpec[] { + return specs.flatMap((spec) => (spec.type === "namespace" ? spec.tools : [spec])); +} + type CodexPromptSnapshotApi = { resolveCodexPromptSnapshotAppServerOptions: (pluginConfig?: unknown) => unknown; buildCodexHarnessPromptSnapshot: (params: { @@ -596,10 +611,8 @@ function selectedThreadStartParams(value: Record): Record", dynamicTools: Array.isArray(value.dynamicTools) - ? value.dynamicTools.map((tool) => - tool && typeof tool === "object" && "name" in tool - ? (tool as { name?: unknown }).name - : tool, + ? flattenCodexDynamicToolSpecs(value.dynamicTools as CodexDynamicToolSpec[]).map( + (tool) => tool.name, ) : value.dynamicTools, }; @@ -803,7 +816,8 @@ function renderScenarioSnapshot( heartbeatCollaborationInstructions: scenario.trigger === "heartbeat" ? CODEX_HEARTBEAT_COLLABORATION_INSTRUCTIONS : undefined, }); - const criticalToolSpecs = scenario.dynamicTools.filter((tool) => + const dynamicToolFunctions = flattenCodexDynamicToolSpecs(scenario.dynamicTools); + const criticalToolSpecs = dynamicToolFunctions.filter((tool) => ["message", "heartbeat_respond"].includes(tool.name), ); const dynamicToolsJson = stableJson(scenario.dynamicTools); @@ -863,7 +877,7 @@ function renderScenarioSnapshot( ...renderModelBoundPromptLayers({ scenario, codexSnapshot, dynamicToolsJson }), "## Dynamic Tool Names", "", - markdownFence("json", stableJson(scenario.dynamicTools.map((tool) => tool.name))), + markdownFence("json", stableJson(dynamicToolFunctions.map((tool) => tool.name))), "", "## Critical Visible-Reply Tool Specs", "",