mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(codex): sync app-server dynamic tool protocol
This commit is contained in:
@@ -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[];
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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>): AnyAgentTool {
|
||||
return {
|
||||
@@ -115,6 +115,20 @@ function expectDynamicSpec(
|
||||
}
|
||||
}
|
||||
|
||||
function flattenSpecsWithNamespace(
|
||||
specs: readonly CodexDynamicToolSpec[],
|
||||
): Array<CodexDynamicToolFunctionSpec & { namespace?: string }> {
|
||||
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", () => {
|
||||
|
||||
@@ -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<string>;
|
||||
}): 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[]): {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+74
-5
@@ -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": {
|
||||
|
||||
+74
-5
@@ -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": {
|
||||
|
||||
+66
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> {
|
||||
const active = new Set<string>();
|
||||
for (const event of events) {
|
||||
@@ -366,7 +374,7 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
|
||||
"features.code_mode_only"?: boolean;
|
||||
mcp_servers?: Record<string, unknown>;
|
||||
};
|
||||
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",
|
||||
|
||||
@@ -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<typeof startOrResumeThread>[0]["dynamicTools"][number] {
|
||||
return {
|
||||
type: "function",
|
||||
name: "message",
|
||||
description,
|
||||
inputSchema: {
|
||||
@@ -169,6 +175,7 @@ function createNamedDynamicTool(
|
||||
name: string,
|
||||
): Parameters<typeof startOrResumeThread>[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<CodexDynamicToolFunctionSpec & { namespace?: string }> {
|
||||
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<string, unknown> | undefined;
|
||||
const startConfig = startParams?.config as Record<string, unknown> | 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<string, unknown> | undefined;
|
||||
const startConfig = startRequestParams?.config as Record<string, unknown> | 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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof writeRawCodexAppS
|
||||
function createMessageDynamicTool(
|
||||
description: string,
|
||||
actions: string[] = ["send"],
|
||||
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
|
||||
): CodexDynamicToolFunctionSpec {
|
||||
return {
|
||||
type: "function",
|
||||
name: "message",
|
||||
description,
|
||||
inputSchema: {
|
||||
@@ -84,10 +86,9 @@ function createMessageDynamicTool(
|
||||
};
|
||||
}
|
||||
|
||||
function createNamedDynamicTool(
|
||||
name: string,
|
||||
): Parameters<typeof startOrResumeThread>[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<typeof startOrResumeThread>[0]["dynamicTools"][number] {
|
||||
return {
|
||||
...createNamedDynamicTool(name),
|
||||
namespace: "openclaw",
|
||||
deferLoading: true,
|
||||
type: "namespace",
|
||||
name: "openclaw",
|
||||
description: "",
|
||||
tools: [{ ...createNamedDynamicTool(name), deferLoading: true }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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<typeof createCodexTrajectoryRecorder>[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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<DynamicToolNamespaceTool>"],
|
||||
},
|
||||
{
|
||||
file: "v2/CommandExecutionApprovalDecision.ts",
|
||||
snippets: ['"accept"', '"acceptForSession"', '"decline"', '"cancel"'],
|
||||
|
||||
@@ -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<string, unknown>): Record<strin
|
||||
...value,
|
||||
developerInstructions: "<see Reconstructed Model-Bound Prompt Layers>",
|
||||
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",
|
||||
"",
|
||||
|
||||
Reference in New Issue
Block a user