Add structured heartbeat responses and Codex tool replies

* Add structured heartbeat response tool

* agents: default codex replies to tools

* agents: use flat heartbeat tool enums
This commit is contained in:
pashpashpash
2026-05-01 11:30:41 -07:00
committed by GitHub
parent bee47a8be9
commit 439d8edf68
39 changed files with 780 additions and 25 deletions

View File

@@ -23,6 +23,9 @@ export function createCodexAppServerAgentHarness(options?: {
return {
id: options?.id ?? "codex",
label: options?.label ?? "Codex agent harness",
deliveryDefaults: {
sourceVisibleReplies: "message_tool",
},
supports: (ctx) => {
const provider = ctx.provider.trim().toLowerCase();
if (providerIds.has(provider)) {

View File

@@ -44,6 +44,7 @@ describe("codex plugin", () => {
expect(registerAgentHarness.mock.calls[0]?.[0]).toMatchObject({
id: "codex",
label: "Codex agent harness",
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
dispose: expect.any(Function),
});
expect(registerMediaUnderstandingProvider.mock.calls[0]?.[0]).toMatchObject({
@@ -89,6 +90,7 @@ describe("codex plugin", () => {
it("only claims the codex provider by default", () => {
const harness = createCodexAppServerAgentHarness();
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
expect(
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
.supported,

View File

@@ -1,6 +1,9 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
import { wrapToolWithBeforeToolCallHook } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
@@ -212,6 +215,38 @@ describe("createCodexDynamicToolBridge", () => {
});
});
it("records heartbeat response tool outcomes", async () => {
const bridge = createBridgeWithToolResult(
HEARTBEAT_RESPONSE_TOOL_NAME,
textToolResult("Recorded.", {
status: "recorded",
outcome: "needs_attention",
notify: true,
summary: "Build is blocked.",
notificationText: "Build is blocked on missing credentials.",
priority: "high",
}),
);
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: HEARTBEAT_RESPONSE_TOOL_NAME,
arguments: {},
});
expect(result).toEqual(expectInputText("Recorded."));
expect(bridge.telemetry.heartbeatToolResponse).toEqual({
outcome: "needs_attention",
notify: true,
summary: "Build is blocked.",
notificationText: "Build is blocked on missing credentials.",
priority: "high",
});
});
it("applies agent tool result middleware from the active plugin registry", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(

View File

@@ -5,11 +5,14 @@ import {
createCodexAppServerToolResultExtensionRunner,
extractToolResultMediaArtifact,
filterToolResultMediaUrls,
HEARTBEAT_RESPONSE_TOOL_NAME,
isToolWrappedWithBeforeToolCallHook,
isMessagingTool,
isMessagingToolSendAction,
normalizeHeartbeatToolResponse,
runAgentHarnessAfterToolCallHook,
type AnyAgentTool,
type HeartbeatToolResponse,
type MessagingToolSend,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
@@ -32,6 +35,7 @@ export type CodexDynamicToolBridge = {
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls: string[];
toolAudioAsVoice: boolean;
successfulCronAdds?: number;
@@ -190,6 +194,12 @@ function collectToolTelemetry(params: {
if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) {
params.telemetry.successfulCronAdds = (params.telemetry.successfulCronAdds ?? 0) + 1;
}
if (!params.isError && params.toolName === HEARTBEAT_RESPONSE_TOOL_NAME) {
const response = normalizeHeartbeatToolResponse(params.result?.details);
if (response) {
params.telemetry.heartbeatToolResponse = response;
}
}
if (!params.isError && params.result) {
const media = extractToolResultMediaArtifact(params.result);
if (media) {
@@ -256,6 +266,7 @@ function isToolResultError(result: AgentToolResult<unknown>): boolean {
status !== "ok" &&
status !== "success" &&
status !== "completed" &&
status !== "recorded" &&
status !== "running"
);
}

View File

@@ -15,6 +15,7 @@ import {
type AgentMessage,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
type HeartbeatToolResponse,
type MessagingToolSend,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { readCodexTurn } from "./protocol-validators.js";
@@ -32,6 +33,7 @@ export type CodexAppServerToolTelemetry = {
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls?: string[];
toolAudioAsVoice?: boolean;
successfulCronAdds?: number;
@@ -232,6 +234,7 @@ export class CodexAppServerEventProjector {
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
heartbeatToolResponse: toolTelemetry.heartbeatToolResponse,
toolMediaUrls: toolTelemetry.toolMediaUrls,
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
successfulCronAdds: toolTelemetry.successfulCronAdds,

View File

@@ -360,12 +360,14 @@ describe("runCodexAppServerAttempt", () => {
"update_plan",
"web_search",
"message",
"heartbeat_respond",
"sessions_spawn",
].map((name) => ({ name }));
expect(__testing.applyCodexDynamicToolProfile(tools, {}).map((tool) => tool.name)).toEqual([
"web_search",
"message",
"heartbeat_respond",
"sessions_spawn",
]);
});

View File

@@ -1398,6 +1398,8 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: params.disableMessageTool,
enableHeartbeatTool: params.trigger === "heartbeat",
forceHeartbeatTool: params.trigger === "heartbeat",
onYield: (message) => {
input.onYieldDetected();
emitCodexAppServerEvent(params, {