diff --git a/docs/plugins/sdk-agent-harness/attempt-runtime.md b/docs/plugins/sdk-agent-harness/attempt-runtime.md index b9cc64d9e54a..77770db5190a 100644 --- a/docs/plugins/sdk-agent-harness/attempt-runtime.md +++ b/docs/plugins/sdk-agent-harness/attempt-runtime.md @@ -150,10 +150,16 @@ extracts its text parts and `idempotencyKey` for ordinary and authorized hooks. String input and a separate `currentUserMessageId` remain supported. The harness owns the fallback when no admitted message exists. +Supply `messages` as an array or an async loader, which runs only when a `before_prompt_build` +hook needs history. Heartbeat-only contributions do not read conversation history. +The production-private `resolveAgentHarnessHistoryLimits` helper applies the shared +Codex and Agents API transcript read budget. + The `developerInstructions.build` callback receives `toolsAllow` and `hasToolRestrictions`. Omitted policy or a trimmed `*` entry is unrestricted; -an empty list or a list without `*` is restrictive. The backend must apply or -reject restrictions inside that callback, before authorized recall runs. +an empty list or a list without `*` is restrictive. Backends enforcing per-turn +restrictions apply or reject them inside that callback, before authorized recall +runs. Agents API continues with hook context but does not enforce hook tool lists. Official harnesses use the JavaScript-only private `openclaw/plugin-sdk/agent-harness-attempt-runtime` for deadlines, cancellation, diff --git a/extensions/agentsapi/README.md b/extensions/agentsapi/README.md index 4a5988494c9f..a9164611df35 100644 --- a/extensions/agentsapi/README.md +++ b/extensions/agentsapi/README.md @@ -6,6 +6,20 @@ environment. Select it through `agents.defaults.agentRuntime.id` or an agent's Multi-user Gateways are not supported by the Agents API MVP. +Ordinary conversation attempts run OpenClaw's shared `before_prompt_build` hook, +including tool-authorized recall and heartbeat prompt contributions. Per-turn +`prependContext` and `appendContext` are applied on both new and resumed sessions. +System-prompt additions and overrides are captured only when the native session +is created. Updating system instructions on an existing native session is an MVP +implementation gap; reset the OpenClaw session to adopt those changes. The harness +does not move system instructions into user messages. Hook `toolsAllow` restrictions +are ignored because the harness cannot enforce turn-scoped restrictions across +Gateway and native tools. Turns continue with the hook's prompt context even for +an empty tool list; other available tools remain usable. Existing configured Gateway +tool policies still apply. Use a runtime that supports per-turn restrictions when a +hook's tool list must be enforced. Steering messages and isolated completions do not +run these conversation prompt hooks. + Memory Core dreaming can generate its diary narrative in a fresh Agents API session without an executor, supplied functions, native web search, vaults, or subagents. These calls use the prepared model and API key, do not reuse the diff --git a/extensions/agentsapi/agentsapi-attempt.instructions.test.ts b/extensions/agentsapi/agentsapi-attempt.instructions.test.ts index 5e983b81e3f5..cc0bd93ecfa2 100644 --- a/extensions/agentsapi/agentsapi-attempt.instructions.test.ts +++ b/extensions/agentsapi/agentsapi-attempt.instructions.test.ts @@ -1,6 +1,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { AgentHarnessAttemptParamsV2 } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + createMockPluginRegistry, + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "openclaw/plugin-sdk/plugin-test-runtime"; import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runAgentsApiAttempt } from "./agentsapi-attempt.js"; @@ -11,6 +16,7 @@ const { fetchWithSsrFGuardMock, prepareAgentWorkspaceContextMock, watchedSessionsContextMock, + openModelContextAsyncMock, promptFixture, } = vi.hoisted(() => ({ fetchWithSsrFGuardMock: @@ -23,6 +29,9 @@ const { vi.fn< typeof import("openclaw/plugin-sdk/agent-harness-runtime").buildWatchedSessionsHarnessContext >(), + openModelContextAsyncMock: vi.fn(async () => ({ + buildSessionContext: () => ({ messages: [{ role: "user", content: "Earlier request" }] }), + })), promptFixture: { declarations: [] as AgentsApiToolSurface["declarations"], turnInputs: [] as string[], @@ -58,7 +67,10 @@ vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async () => { }); vi.mock("openclaw/plugin-sdk/agent-sessions", () => ({ - SessionManager: { open: () => ({ buildSessionContext: () => ({ messages: [] }) }) }, + SessionManager: { + open: () => ({ buildSessionContext: () => ({ messages: [] }) }), + openModelContextAsync: openModelContextAsyncMock, + }, })); vi.mock("./agentsapi-tools.js", () => ({ @@ -114,6 +126,8 @@ const tempDirs = useAutoCleanupTempDirTracker(afterEach); afterEach(() => fetchWithSsrFGuardMock.mockReset()); afterEach(() => prepareAgentWorkspaceContextMock.mockClear()); afterEach(() => watchedSessionsContextMock.mockClear()); +afterEach(() => openModelContextAsyncMock.mockReset()); +afterEach(() => resetGlobalHookRunner()); afterEach(() => { promptFixture.declarations = []; promptFixture.turnInputs = []; @@ -121,6 +135,94 @@ afterEach(() => { }); describe("Agents API agent workspace instructions", () => { + it("captures plugin system instructions once and refreshes plugin context on resume", async () => { + const fixture = await createFixture({ + trigger: "user", + toolAuthorityFingerprint: "fixture-prompt-authority", + contextTokenBudget: 32_000, + }); + promptFixture.declarations = toolDeclarations("memory_search", "memory_get"); + const hook = vi.fn().mockReturnValue({ + prependSystemContext: "Plugin system guidance one.", + prependContext: "Reminder one.", + appendContext: "Plugin trailing context.", + }); + const recall = vi.fn().mockReturnValue({ prependContext: "Authorized recall context." }); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { hookName: "before_prompt_build", handler: hook }, + { hookName: "before_prompt_build", handler: recall, requiresToolAuthority: true }, + ]), + ); + + const binding = await fixture.run(); + expect(openModelContextAsyncMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + limits: { maxBytes: 256_000, maxEvents: 10_000, toolResultOverflow: "omit" }, + }), + ); + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ messages: [{ role: "user", content: "Earlier request" }] }), + expect.anything(), + ); + expect(fixture.requests[0]?.agent.instructions).toContain("Plugin system guidance one."); + expect(promptFixture.turnInputs[0]).toContain( + "Reminder one.\n\nAuthorized recall context.\n\nFixture prompt\n\nPlugin trailing context.", + ); + hook.mockReturnValue({ + prependSystemContext: "Plugin system guidance two.", + prependContext: "Reminder two.", + }); + await fixture.run(binding, { prompt: "Continued request" }); + expect(fixture.requests[1]).toEqual({ agent: { reasoning: { effort: null } } }); + expect(promptFixture.turnInputs[1]).toContain( + "Reminder two.\n\nAuthorized recall context.\n\nContinued request", + ); + + await fixture.run(); + expect(fixture.requests[2]?.agent.instructions).toContain("Plugin system guidance two."); + }); + + it("continues without transcript access when no prompt hook needs history", async () => { + const fixture = await createFixture(); + openModelContextAsyncMock.mockRejectedValue(new Error("History is unavailable")); + await fixture.run(); + expect(promptFixture.turnInputs[0]).toContain("Fixture prompt"); + }); + + it.each([ + { resumed: false, toolsAllow: [] }, + { resumed: true, toolsAllow: [] }, + { resumed: false, toolsAllow: ["memory_search"] }, + { resumed: true, toolsAllow: ["memory_search"] }, + ])( + "continues with plugin context when tool restrictions cannot be enforced ($resumed, $toolsAllow)", + async ({ resumed, toolsAllow }) => { + const fixture = await createFixture({ toolAuthorityFingerprint: "fixture-prompt-authority" }); + const binding = resumed ? await fixture.run() : undefined; + const requestCount = fixture.requests.length; + const inputCount = promptFixture.turnInputs.length; + const recall = vi.fn().mockReturnValue({ prependContext: "Authorized recall context." }); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_prompt_build", + handler: () => ({ toolsAllow, prependContext: "Ordinary plugin context." }), + }, + { hookName: "before_prompt_build", handler: recall, requiresToolAuthority: true }, + ]), + ); + await fixture.run(binding); + expect(fixture.requests).toHaveLength(requestCount + 1); + expect(promptFixture.turnInputs).toHaveLength(inputCount + 1); + expect(promptFixture.turnInputs[inputCount]).toContain( + "Ordinary plugin context.\n\nAuthorized recall context.\n\nFixture prompt", + ); + expect(recall).toHaveBeenCalledOnce(); + }, + ); + it("sends the Gateway workspace snapshot once, preserves it on resume, and refreshes it for a new session", async () => { const fixture = await createFixture(); const instructionsPath = path.join(fixture.workspace, "AGENTS.md"); diff --git a/extensions/agentsapi/agentsapi-attempt.ts b/extensions/agentsapi/agentsapi-attempt.ts index ee981da478d1..898621580e75 100644 --- a/extensions/agentsapi/agentsapi-attempt.ts +++ b/extensions/agentsapi/agentsapi-attempt.ts @@ -7,6 +7,7 @@ import { emitAgentHarnessAttemptEvent, AgentHarnessProjectionSettlement, racePromiseWithAbortSignal, + resolveAgentHarnessHistoryLimits, type AgentHarnessAttemptTimeout, } from "openclaw/plugin-sdk/agent-harness-attempt-runtime"; import { @@ -18,6 +19,7 @@ import { embeddedAgentLog, formatErrorMessage, resolveAgentDir, + resolveAgentHarnessBeforePromptBuildResult, runAgentEndSideEffects, runAgentHarnessLlmOutputHook, sanitizeToolArgs, @@ -122,6 +124,25 @@ export async function runAgentsApiAttempt( state: { lifecycleStarted: false, lifecycleTerminalEmitted: false }, emitEvent, }); + const contextWindow = { + contextTokenBudget: params.contextWindowInfo?.tokens ?? params.contextTokenBudget, + contextWindowSource: params.contextWindowInfo?.source, + contextWindowReferenceTokens: params.contextWindowInfo?.referenceTokens, + }; + const hookContext = { + runId: params.runId, + agentId: target.agentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir: params.workspaceDir, + modelProviderId: params.provider, + modelId: params.model.id, + trigger: params.trigger, + inputProvenance: params.inputProvenance, + ...buildAgentHookContextChannelFields(params), + channelContext: params.channelContext, + ...contextWindow, + }; let native: ReturnType | undefined; let remoteSessionId = binding?.sessionId; let terminal: ReturnType = { kind: "ok" }; @@ -222,18 +243,59 @@ export async function runAgentsApiAttempt( const client = new AgentsApiClient(params.resolvedApiKey!, assertOwnerCurrent); const reasoningEffort = resolveAgentsApiReasoningEffort(params); const creatingSession = !remoteSessionId; + const instructions = creatingSession + ? await buildAgentsApiInstructions(params, surface.declarations) + : ""; + assertCurrent(); + const admittedMessage = + params.userTurnTranscriptRecorder?.message ?? + (await params.userTurnTranscriptRecorder?.resolveMessage()); + assertCurrent(); + const promptBuild = await resolveAgentHarnessBeforePromptBuildResult({ + prompt: params.prompt, + currentInboundContext: params.currentInboundContext, + currentUserMessage: admittedMessage ?? params.prompt, + // Agents API cannot narrow native tools per turn; hook toolsAllow is advisory here. + developerInstructions: instructions, + messages: async () => { + assertCurrent(); + const history = await SessionManager.openModelContextAsync(target, { + cwd: params.workspaceDir, + admission: params.userTurnTranscriptRecorder?.getAdmissionReceipt(), + signal: controller.signal, + limits: resolveAgentHarnessHistoryLimits( + params.contextWindowInfo?.tokens ?? params.contextTokenBudget, + ), + }); + assertCurrent(); + return history.buildSessionContext().messages; + }, + ctx: hookContext, + bootstrapContextRunKind: params.bootstrapContextRunKind, + toolAuthority: { + fingerprint: params.toolAuthorityFingerprint, + activeToolNames: () => surface.declarations.map((tool) => tool.name), + assertActive: assertCurrent, + }, + }); + assertCurrent(); if (!remoteSessionId) { - // The remote session owns this snapshot; continuation never reloads it. - const instructions = await buildAgentsApiInstructions(params, surface.declarations); - assertCurrent(); - remoteSessionId = await client.create(controller.signal, instructions, params.model.id, { - functions: surface.declarations, - files: inputs.files, - reasoning: { - effort: reasoningEffort, - ...(params.reasoningLevel && params.reasoningLevel !== "off" ? { summary: "auto" } : {}), + // System hook contributions share the native session's immutable instruction snapshot. + remoteSessionId = await client.create( + controller.signal, + promptBuild.developerInstructions, + params.model.id, + { + functions: surface.declarations, + files: inputs.files, + reasoning: { + effort: reasoningEffort, + ...(params.reasoningLevel && params.reasoningLevel !== "off" + ? { summary: "auto" } + : {}), + }, }, - }); + ); assertCurrent(); await bind({ sessionId: remoteSessionId, authFingerprint: fingerprint }); } else { @@ -332,7 +394,7 @@ export async function runAgentsApiAttempt( const result = await native.run( [ buildAgentsApiTurnContext(params, surface.declarations), - buildCurrentInboundPrompt({ context: params.currentInboundContext, prompt: params.prompt }), + promptBuild.prompt, inputs.mappingText, ] .filter(Boolean) @@ -514,25 +576,6 @@ export async function runAgentsApiAttempt( }, }; assertHarnessCurrent(); - const contextWindow = { - contextTokenBudget: params.contextWindowInfo?.tokens ?? params.contextTokenBudget, - contextWindowSource: params.contextWindowInfo?.source, - contextWindowReferenceTokens: params.contextWindowInfo?.referenceTokens, - }; - const hookContext = { - runId: params.runId, - agentId: target.agentId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - workspaceDir: params.workspaceDir, - modelProviderId: params.provider, - modelId: params.model.id, - trigger: params.trigger, - inputProvenance: params.inputProvenance, - ...buildAgentHookContextChannelFields(params), - channelContext: params.channelContext, - ...contextWindow, - }; runAgentHarnessLlmOutputHook({ event: { runId: params.runId, diff --git a/extensions/codex/src/app-server/session-history.ts b/extensions/codex/src/app-server/session-history.ts index 8d42422f9b86..6277213684e8 100644 --- a/extensions/codex/src/app-server/session-history.ts +++ b/extensions/codex/src/app-server/session-history.ts @@ -1,4 +1,5 @@ /** Reads bounded model context from the Codex transcript mirror. */ +import { resolveAgentHarnessHistoryLimits } from "openclaw/plugin-sdk/agent-harness-attempt-runtime"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { @@ -102,14 +103,7 @@ export async function readCodexMirroredSessionHistoryMessages( const loaded = await SessionManager.openModelContextAsync(resolved.target, { admission, signal, - limits: { - maxBytes: Math.min( - 64 * 1024 * 1024, - Math.max(1024, Math.floor((contextTokenBudget ?? 128_000) * 8)), - ), - maxEvents: 10_000, - toolResultOverflow: "omit", - }, + limits: resolveAgentHarnessHistoryLimits(contextTokenBudget), }); result = consumeCodexHistory( loaded.buildSessionContext().messages, diff --git a/src/agents/harness/prompt-compaction-hook-helpers.test.ts b/src/agents/harness/prompt-compaction-hook-helpers.test.ts index b80a1c8e71f2..5e1e5490d066 100644 --- a/src/agents/harness/prompt-compaction-hook-helpers.test.ts +++ b/src/agents/harness/prompt-compaction-hook-helpers.test.ts @@ -18,6 +18,7 @@ describe("resolveAgentHarnessBeforePromptBuildResult", () => { "preserves the admitted request through projected prompts (authorized=%s)", async (authorized) => { const handler = vi.fn(async (_event: unknown) => undefined); + const history = [{ role: "user", content: "Earlier request" }]; initializeGlobalHookRunner( createMockPluginRegistry([ { @@ -35,7 +36,7 @@ describe("resolveAgentHarnessBeforePromptBuildResult", () => { }, currentUserMessage: "hello", currentUserMessageId: "message-1", - messages: [], + messages: async () => history, developerInstructions: "base", ctx: {}, toolAuthority: { @@ -53,10 +54,36 @@ describe("resolveAgentHarnessBeforePromptBuildResult", () => { currentUserMessage: "hello", currentUserMessageId: "message-1", prompt: expect.stringContaining("Prior conversation:"), + messages: history, }); }, ); + it.each([false, true])( + "builds prompts without reading history when only heartbeat hooks can run (heartbeat=%s)", + async (heartbeat) => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "heartbeat_prompt_contribution", + handler: () => ({ prependContext: "Heartbeat reminder." }), + }, + ]), + ); + const result = await resolveAgentHarnessBeforePromptBuildResult({ + prompt: "Current request", + developerInstructions: "base", + messages: async () => { + throw new Error("History is unavailable"); + }, + ctx: { trigger: heartbeat ? "heartbeat" : "user" }, + }); + expect(result.prompt).toBe( + heartbeat ? "Heartbeat reminder.\n\nCurrent request" : "Current request", + ); + }, + ); + it.each([ { content: "hello", expected: "hello" }, { diff --git a/src/agents/harness/prompt-compaction-hook-helpers.ts b/src/agents/harness/prompt-compaction-hook-helpers.ts index 7cc6baaf0c80..9730b7b237c7 100644 --- a/src/agents/harness/prompt-compaction-hook-helpers.ts +++ b/src/agents/harness/prompt-compaction-hook-helpers.ts @@ -32,7 +32,7 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: { currentUserMessage?: string | Pick; currentUserMessageId?: string; developerInstructions: string | AgentHarnessDeveloperInstructionBuilder; - messages: unknown[]; + messages: unknown[] | (() => Promise); ctx: AgentHarnessHookContext; bootstrapContextRunKind?: BootstrapContextRunKind; toolAuthority?: { @@ -83,7 +83,11 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: { ? { currentUserMessage: currentUserMessageText } : {}), ...(typeof currentUserMessageId === "string" ? { currentUserMessageId } : {}), - messages: params.messages, + messages: hasPromptBuildHooks + ? typeof params.messages === "function" + ? await params.messages() + : params.messages + : [], }; // Match the embedded runner's lifecycle order: heartbeat contributions are diff --git a/src/agents/harness/prompt-context.ts b/src/agents/harness/prompt-context.ts index 4def8a21a307..4635ea0bb0d9 100644 --- a/src/agents/harness/prompt-context.ts +++ b/src/agents/harness/prompt-context.ts @@ -1,8 +1,23 @@ import path from "node:path"; +import type { SessionModelContextLimits } from "../../config/sessions/session-accessor.sqlite-model-context.js"; import type { OpenClawConfig } from "../../config/types.js"; import { resolveAgentWorkspaceDir } from "../agent-scope-config.js"; import type { AgentHarnessAttemptParamsV2 } from "./types.js"; +/** Bound transcript reads to the model budget while preserving complete messages. */ +export function resolveAgentHarnessHistoryLimits( + contextTokenBudget?: number, +): SessionModelContextLimits { + return { + maxBytes: Math.min( + 64 * 1024 * 1024, + Math.max(1024, Math.floor((contextTokenBudget ?? 128_000) * 8)), + ), + maxEvents: 10_000, + toolResultOverflow: "omit", + }; +} + export function shouldIncludeAgentHarnessRuntimeContext( params: Pick, ): boolean { diff --git a/src/plugin-sdk/agent-harness-attempt-runtime.ts b/src/plugin-sdk/agent-harness-attempt-runtime.ts index 0578f8ecd48a..1e1624661697 100644 --- a/src/plugin-sdk/agent-harness-attempt-runtime.ts +++ b/src/plugin-sdk/agent-harness-attempt-runtime.ts @@ -14,6 +14,7 @@ export { } from "../agents/harness/attempt-events.js"; export { selectSupportedReasoningEffort } from "../agents/harness/reasoning-effort.js"; export { + resolveAgentHarnessHistoryLimits, resolveAgentWorkspaceMemoryRouting, shouldIncludeAgentHarnessRuntimeContext, } from "../agents/harness/prompt-context.js";