fix(plugins): expose effective context budget in hooks

Add optional context budget/source/reference metadata to plugin hook contexts plus llm_output and sanitized model_call_* hook events.

Thread the existing resolved context-window info through Pi embedded runs, CLI harness runs, and Codex app-server hook emission so plugins can observe the effective budget after agent/model/config caps.

Document the metadata and cover the CLI, Pi, Codex app-server, and model-call paths with focused tests.

Fixes #64327.
This commit is contained in:
Val Alexander
2026-05-14 17:51:53 -05:00
committed by GitHub
parent 4004c9342d
commit eb4e20ca1d
16 changed files with 199 additions and 5 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Codex app-server: stream commentary preambles into editable channel progress drafts without promoting them to final answers.
- Codex migration: remove the bundled `codex-cli` backend and repair legacy `codex-cli/*` model refs to the Codex app-server route on `openai/*`.
- Gateway/startup: add owner-level startup trace attribution for auth, plugin loading, lookup counts, and plugin sidecar services. (#81738) Thanks @samzong.
- Plugins/hooks: expose the resolved effective `contextTokenBudget` plus source/reference metadata on `llm_output` and sanitized `model_call_*` hook events/contexts so plugin cost and context-health alerts can use agent-level context caps. Fixes #64327. Thanks @BunsDev.
- Channels/status reactions: wire `StatusReactionController` into WhatsApp message turns (queued → thinking → tool → done/error lifecycle, on par with Telegram and Discord), add `deploy`/`build`/`concierge` emoji categories with tool-token routing, and replace the status reaction defaults with self-explanatory emoji (🧠 thinking, 🛠️ tool, 💻 coding, 🌐 web, ⏳ stallSoft, ⚠️ stallHard, ✅ done, ❌ error, 🗜️ compacting) so stall and lifecycle reactions read as status indicators instead of emotional commentary. Fixes #59077. (#80612) Thanks @gado-ships-it.
- Control UI: add a browser-local Text size setting in Appearance and Quick Settings, scaling chat and dense UI text while keeping inputs above the mobile Safari focus-zoom threshold. Fixes #8547. Thanks @BunsDev.
- Docs: add a dedicated ds4 provider page with local DeepSeek V4 Flash config, on-demand startup, context sizing, and live verification steps.

View File

@@ -114,7 +114,7 @@ observation-only.
- `model_call_started` / `model_call_ended` - observe sanitized provider/model call metadata, timing, outcome, and bounded request-id hashes without prompt or response content
- `llm_input` - observe provider input (system prompt, prompt, history)
- `llm_output` - observe provider output
- `llm_output` - observe provider output, usage, and the resolved `contextTokenBudget` when available
**Tools**
@@ -287,7 +287,11 @@ that should not receive raw prompts, history, responses, headers, request
bodies, or provider request IDs. These hooks include stable metadata such as
`runId`, `callId`, `provider`, `model`, optional `api`/`transport`, terminal
`durationMs`/`outcome`, and `upstreamRequestIdHash` when OpenClaw can derive a
bounded provider request-id hash.
bounded provider request-id hash. When the runtime has resolved context-window
metadata, the hook event and context also include `contextTokenBudget`, the
effective token budget after model/config/agent caps, plus
`contextWindowSource` and `contextWindowReferenceTokens` when a lower cap was
applied.
`before_agent_finalize` runs only when a harness is about to accept a natural
final assistant answer. It is not the `/stop` cancellation path and does not

View File

@@ -62,6 +62,12 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
provider: "codex",
modelId: "gpt-5.4-codex",
model: createCodexTestModel("codex"),
contextTokenBudget: 150_000,
contextWindowInfo: {
tokens: 150_000,
referenceTokens: 200_000,
source: "agentContextTokens",
},
thinkLevel: "medium",
disableTools: true,
timeoutMs: 5_000,
@@ -2647,19 +2653,34 @@ describe("runCodexAppServerAttempt", () => {
resolvedRef?: string;
runId?: string;
sessionId?: string;
contextTokenBudget?: number;
contextWindowSource?: string;
contextWindowReferenceTokens?: number;
},
{
runId?: string;
sessionId?: string;
contextTokenBudget?: number;
contextWindowSource?: string;
contextWindowReferenceTokens?: number;
},
{ runId?: string; sessionId?: string },
];
expect(llmOutputPayload.runId).toBe("run-1");
expect(llmOutputPayload.sessionId).toBe("session-1");
expect(llmOutputPayload.provider).toBe("codex");
expect(llmOutputPayload.model).toBe("gpt-5.4-codex");
expect(llmOutputPayload.contextTokenBudget).toBe(150_000);
expect(llmOutputPayload.contextWindowSource).toBe("agentContextTokens");
expect(llmOutputPayload.contextWindowReferenceTokens).toBe(200_000);
expect(llmOutputPayload.resolvedRef).toBe("codex/gpt-5.4-codex");
expect(llmOutputPayload.harnessId).toBe("codex");
expect(llmOutputPayload.assistantTexts).toEqual(["hello back"]);
expect(llmOutputPayload.lastAssistant?.role).toBe("assistant");
expect(llmOutputContext.runId).toBe("run-1");
expect(llmOutputContext.sessionId).toBe("session-1");
expect(llmOutputContext.contextTokenBudget).toBe(150_000);
expect(llmOutputContext.contextWindowSource).toBe("agentContextTokens");
expect(llmOutputContext.contextWindowReferenceTokens).toBe(200_000);
const [agentEndPayload, agentEndContext] = mockCall(agentEnd, "agent_end") as [
{ messages?: Array<{ role?: string }>; success?: boolean },
{ runId?: string; sessionId?: string },

View File

@@ -561,6 +561,19 @@ export async function runCodexAppServerAttempt(
});
const hadSessionFile = await pathExists(params.sessionFile);
let historyMessages = (await readMirroredSessionHistoryMessages(params.sessionFile)) ?? [];
const hookContextWindowFields = {
...(params.contextWindowInfo?.tokens
? { contextTokenBudget: params.contextWindowInfo.tokens }
: params.contextTokenBudget
? { contextTokenBudget: params.contextTokenBudget }
: {}),
...(params.contextWindowInfo?.source
? { contextWindowSource: params.contextWindowInfo.source }
: {}),
...(params.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: params.contextWindowInfo.referenceTokens }
: {}),
};
const hookContext = {
runId: params.runId,
agentId: sessionAgentId,
@@ -570,6 +583,7 @@ export async function runCodexAppServerAttempt(
messageProvider: params.messageProvider ?? undefined,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
...hookContextWindowFields,
};
if (activeContextEngine) {
await bootstrapHarnessContextEngine({
@@ -1557,6 +1571,7 @@ export async function runCodexAppServerAttempt(
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
...hookContextWindowFields,
resolvedRef:
params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`,
...(params.runtimePlan?.observability.harnessId
@@ -1798,6 +1813,7 @@ export async function runCodexAppServerAttempt(
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
...hookContextWindowFields,
resolvedRef:
params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`,
...(params.runtimePlan?.observability.harnessId

View File

@@ -147,6 +147,11 @@ function buildPreparedContext(params?: {
reusableCliSession: params?.cliSessionId ? { sessionId: params.cliSessionId } : {},
modelId: "gpt-5.4",
normalizedModel: "gpt-5.4",
contextWindowInfo: {
tokens: 150_000,
referenceTokens: 200_000,
source: "agentContextTokens",
},
systemPrompt: "You are a helpful assistant.",
systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"],
bootstrapPromptWarningLines: [],
@@ -665,13 +670,22 @@ describe("runCliAgent reliability", () => {
expect(llmOutputEvent.sessionId).toBe("s1");
expect(llmOutputEvent.provider).toBe("codex-cli");
expect(llmOutputEvent.model).toBe("gpt-5.4");
expect(llmOutputEvent.contextTokenBudget).toBe(150_000);
expect(llmOutputEvent.contextWindowSource).toBe("agentContextTokens");
expect(llmOutputEvent.contextWindowReferenceTokens).toBe(200_000);
expect(llmOutputEvent.assistantTexts).toEqual(["hello from cli"]);
const lastAssistant = requireRecord(llmOutputEvent.lastAssistant, "last assistant");
expect(lastAssistant.role).toBe("assistant");
expect(lastAssistant.content).toEqual([{ type: "text", text: "hello from cli" }]);
expect(lastAssistant.provider).toBe("codex-cli");
expect(lastAssistant.model).toBe("gpt-5.4");
expect(callArg(hookRunner.runLlmOutput, 0, 1, "llm_output context")).toBeTypeOf("object");
const llmOutputContext = requireRecord(
callArg(hookRunner.runLlmOutput, 0, 1, "llm_output context"),
"llm_output context",
);
expect(llmOutputContext.contextTokenBudget).toBe(150_000);
expect(llmOutputContext.contextWindowSource).toBe("agentContextTokens");
expect(llmOutputContext.contextWindowReferenceTokens).toBe(200_000);
const agentEndEvent = requireRecord(
callArg(hookRunner.runAgentEnd, 0, 0, "agent_end event"),

View File

@@ -166,6 +166,15 @@ export async function runPreparedCliAgent(
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
trigger: params.trigger,
...(context.contextWindowInfo?.tokens
? { contextTokenBudget: context.contextWindowInfo.tokens }
: {}),
...(context.contextWindowInfo?.source
? { contextWindowSource: context.contextWindowInfo.source }
: {}),
...(context.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: context.contextWindowInfo.referenceTokens }
: {}),
...buildAgentHookContextChannelFields(params),
} as const;
@@ -308,6 +317,15 @@ export async function runPreparedCliAgent(
sessionId: params.sessionId,
provider: params.provider,
model: context.modelId,
...(context.contextWindowInfo?.tokens
? { contextTokenBudget: context.contextWindowInfo.tokens }
: {}),
...(context.contextWindowInfo?.source
? { contextWindowSource: context.contextWindowInfo.source }
: {}),
...(context.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: context.contextWindowInfo.referenceTokens }
: {}),
resolvedRef: `${params.provider}/${context.modelId}`,
assistantTexts,
...(lastAssistant ? { lastAssistant } : {}),

View File

@@ -30,6 +30,8 @@ import { CLI_AUTH_EPOCH_VERSION, resolveCliAuthEpoch } from "../cli-auth-epoch.j
import { resolveCliBackendConfig } from "../cli-backends.js";
import { hashCliSessionText, resolveCliSessionReuse } from "../cli-session.js";
import { claudeCliSessionTranscriptHasContent } from "../command/attempt-execution.helpers.js";
import { resolveContextWindowInfo } from "../context-window-guard.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
import {
resolveBootstrapMaxChars,
@@ -156,6 +158,12 @@ export async function prepareCliRunContext(
const modelId = (params.model ?? "default").trim() || "default";
const normalizedModel = normalizeCliModel(modelId, backendResolved.config);
const modelDisplay = `${params.provider}/${modelId}`;
const contextWindowInfo = resolveContextWindowInfo({
cfg: params.config,
provider: params.provider,
modelId,
defaultTokens: DEFAULT_CONTEXT_TOKENS,
});
const sessionLabel = params.sessionKey ?? params.sessionId;
const { bootstrapFiles, contextFiles } = await prepareDeps.resolveBootstrapContextForRun({
@@ -471,6 +479,7 @@ export async function prepareCliRunContext(
reusableCliSession,
modelId,
normalizedModel,
contextWindowInfo,
systemPrompt,
systemPromptReport,
bootstrapPromptWarningLines: bootstrapPromptWarning.lines,

View File

@@ -10,6 +10,7 @@ import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
import type { InputProvenance } from "../../sessions/input-provenance.js";
import type { BootstrapContextMode } from "../bootstrap-files.js";
import type { ResolvedCliBackend } from "../cli-backends.js";
import type { ContextWindowInfo } from "../context-window-guard.js";
import type {
CurrentTurnPromptContext,
EmbeddedRunTrigger,
@@ -114,6 +115,7 @@ export type PreparedCliRunContext = {
reusableCliSession: CliReusableSession;
modelId: string;
normalizedModel: string;
contextWindowInfo?: ContextWindowInfo;
systemPrompt: string;
systemPromptReport: SessionSystemPromptReport;
bootstrapPromptWarningLines: string[];

View File

@@ -1,4 +1,7 @@
import type { PluginHookAgentContext } from "../../plugins/hook-types.js";
import type {
PluginHookAgentContext,
PluginHookContextWindowSource,
} from "../../plugins/hook-types.js";
export type AgentHarnessHookContext = {
runId: string;
@@ -12,6 +15,9 @@ export type AgentHarnessHookContext = {
messageProvider?: string;
trigger?: string;
channelId?: string;
contextTokenBudget?: number;
contextWindowSource?: PluginHookContextWindowSource;
contextWindowReferenceTokens?: number;
};
export function buildAgentHookContext(params: AgentHarnessHookContext): PluginHookAgentContext {
@@ -27,5 +33,10 @@ export function buildAgentHookContext(params: AgentHarnessHookContext): PluginHo
...(params.messageProvider ? { messageProvider: params.messageProvider } : {}),
...(params.trigger ? { trigger: params.trigger } : {}),
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.contextTokenBudget ? { contextTokenBudget: params.contextTokenBudget } : {}),
...(params.contextWindowSource ? { contextWindowSource: params.contextWindowSource } : {}),
...(params.contextWindowReferenceTokens
? { contextWindowReferenceTokens: params.contextWindowReferenceTokens }
: {}),
};
}

View File

@@ -1288,6 +1288,7 @@ export async function runEmbeddedPiAgent(
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
contextEngine,
contextTokenBudget: ctxInfo.tokens,
contextWindowInfo: ctxInfo,
skillsSnapshot: params.skillsSnapshot,
prompt,
transcriptPrompt: params.transcriptPrompt,

View File

@@ -390,6 +390,9 @@ describe("wrapStreamFnWithDiagnosticModelCallEvents", () => {
model: "gpt-5.4",
api: "openai-responses",
transport: "http",
contextTokenBudget: 150_000,
contextWindowSource: "agentContextTokens",
contextWindowReferenceTokens: 200_000,
trace: createDiagnosticTraceContext(),
nextCallId: () => "call-hook",
},
@@ -413,16 +416,25 @@ describe("wrapStreamFnWithDiagnosticModelCallEvents", () => {
expect(startedEvent.model).toBe("gpt-5.4");
expect(startedEvent.api).toBe("openai-responses");
expect(startedEvent.transport).toBe("http");
expect(startedEvent.contextTokenBudget).toBe(150_000);
expect(startedEvent.contextWindowSource).toBe("agentContextTokens");
expect(startedEvent.contextWindowReferenceTokens).toBe(200_000);
const startedCtx = requireMockRecordArg(started, 0, 1, "started hook context");
expect(startedCtx.runId).toBe("run-1");
expect(startedCtx.sessionKey).toBe("session-key");
expect(startedCtx.sessionId).toBe("session-id");
expect(startedCtx.modelProviderId).toBe("openai");
expect(startedCtx.modelId).toBe("gpt-5.4");
expect(startedCtx.contextTokenBudget).toBe(150_000);
expect(startedCtx.contextWindowSource).toBe("agentContextTokens");
expect(startedCtx.contextWindowReferenceTokens).toBe(200_000);
const endedEvent = requireMockRecordArg(ended, 0, 0, "ended hook event");
expect(endedEvent.runId).toBe("run-1");
expect(endedEvent.callId).toBe("call-hook");
expect(endedEvent.outcome).toBe("completed");
expect(endedEvent.contextTokenBudget).toBe(150_000);
expect(endedEvent.contextWindowSource).toBe("agentContextTokens");
expect(endedEvent.contextWindowReferenceTokens).toBe(200_000);
expectNumberField(endedEvent, "durationMs");
expectNumberField(endedEvent, "responseStreamBytes");
expectNumberField(endedEvent, "timeToFirstByteMs");

View File

@@ -19,6 +19,7 @@ import {
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import type {
PluginHookAgentContext,
PluginHookContextWindowSource,
PluginHookModelCallEndedEvent,
PluginHookModelCallStartedEvent,
} from "../../../plugins/hook-types.js";
@@ -33,6 +34,9 @@ type ModelCallDiagnosticContext = {
model: string;
api?: string;
transport?: string;
contextTokenBudget?: number;
contextWindowSource?: PluginHookContextWindowSource;
contextWindowReferenceTokens?: number;
trace: DiagnosticTraceContext;
nextCallId: () => string;
onStarted?: () => void;
@@ -150,6 +154,11 @@ function baseModelCallEvent(
model: ctx.model,
...(ctx.api && { api: ctx.api }),
...(ctx.transport && { transport: ctx.transport }),
...(ctx.contextTokenBudget ? { contextTokenBudget: ctx.contextTokenBudget } : {}),
...(ctx.contextWindowSource ? { contextWindowSource: ctx.contextWindowSource } : {}),
...(ctx.contextWindowReferenceTokens
? { contextWindowReferenceTokens: ctx.contextWindowReferenceTokens }
: {}),
trace,
};
}
@@ -189,6 +198,13 @@ function modelCallHookEventBase(eventBase: ModelCallEventBase): PluginHookModelC
model: eventBase.model,
...(eventBase.api ? { api: eventBase.api } : {}),
...(eventBase.transport ? { transport: eventBase.transport } : {}),
...(eventBase.contextTokenBudget ? { contextTokenBudget: eventBase.contextTokenBudget } : {}),
...(eventBase.contextWindowSource
? { contextWindowSource: eventBase.contextWindowSource }
: {}),
...(eventBase.contextWindowReferenceTokens
? { contextWindowReferenceTokens: eventBase.contextWindowReferenceTokens }
: {}),
};
}
@@ -200,6 +216,13 @@ function modelCallHookContext(eventBase: ModelCallEventBase): PluginHookAgentCon
...(eventBase.sessionId ? { sessionId: eventBase.sessionId } : {}),
modelProviderId: eventBase.provider,
modelId: eventBase.model,
...(eventBase.contextTokenBudget ? { contextTokenBudget: eventBase.contextTokenBudget } : {}),
...(eventBase.contextWindowSource
? { contextWindowSource: eventBase.contextWindowSource }
: {}),
...(eventBase.contextWindowReferenceTokens
? { contextWindowReferenceTokens: eventBase.contextWindowReferenceTokens }
: {}),
}) as PluginHookAgentContext;
}

View File

@@ -2447,6 +2447,15 @@ export async function runEmbeddedAttempt(
model: params.modelId,
api: params.model.api,
transport: effectiveAgentTransport,
...(params.contextWindowInfo?.tokens
? { contextTokenBudget: params.contextWindowInfo.tokens }
: {}),
...(params.contextWindowInfo?.source
? { contextWindowSource: params.contextWindowInfo.source }
: {}),
...(params.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: params.contextWindowInfo.referenceTokens }
: {}),
trace: runTrace,
nextCallId: () => `${params.runId}:model:${(diagnosticModelCallSeq += 1)}`,
onStarted: () => {
@@ -4006,6 +4015,15 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
...(params.contextWindowInfo?.tokens
? { contextTokenBudget: params.contextWindowInfo.tokens }
: {}),
...(params.contextWindowInfo?.source
? { contextWindowSource: params.contextWindowInfo.source }
: {}),
...(params.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: params.contextWindowInfo.referenceTokens }
: {}),
resolvedRef:
params.runtimePlan?.observability.resolvedRef ??
`${params.provider}/${params.modelId}`,
@@ -4024,6 +4042,15 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
trigger: params.trigger,
...(params.contextWindowInfo?.tokens
? { contextTokenBudget: params.contextWindowInfo.tokens }
: {}),
...(params.contextWindowInfo?.source
? { contextWindowSource: params.contextWindowInfo.source }
: {}),
...(params.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: params.contextWindowInfo.referenceTokens }
: {}),
...buildAgentHookContextChannelFields(params),
},
)

View File

@@ -26,12 +26,20 @@ type EmbeddedRunAttemptBase = Omit<
"provider" | "model" | "authProfileId" | "authProfileIdSource" | "thinkLevel" | "lane" | "enqueue"
>;
export type EmbeddedRunContextWindowInfo = {
tokens: number;
referenceTokens?: number;
source: "model" | "modelsConfig" | "agentContextTokens" | "default";
};
export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & {
initialReplayState?: EmbeddedRunReplayState;
/** Pluggable context engine for ingest/assemble/compact lifecycle. */
contextEngine?: ContextEngine;
/** Resolved model context window in tokens for assemble/compact budgeting. */
contextTokenBudget?: number;
/** Source metadata for the resolved model context budget. */
contextWindowInfo?: EmbeddedRunContextWindowInfo;
/** Resolved API key for this run when runtime auth did not replace it. */
resolvedApiKey?: string;
/** Auth profile resolved for this attempt's provider/model call. */

View File

@@ -458,6 +458,9 @@ type DiagnosticModelCallBaseEvent = DiagnosticBaseEvent & {
model: string;
api?: string;
transport?: string;
contextTokenBudget?: number;
contextWindowSource?: "model" | "modelsConfig" | "agentContextTokens" | "default";
contextWindowReferenceTokens?: number;
upstreamRequestIdHash?: string;
};

View File

@@ -196,8 +196,20 @@ export type PluginHookAgentContext = {
messageProvider?: string;
trigger?: string;
channelId?: string;
/** Resolved effective context-token budget after model/config/agent caps. */
contextTokenBudget?: number;
/** Source that supplied the resolved context-token budget. */
contextWindowSource?: PluginHookContextWindowSource;
/** Native/configured reference window when a lower cap wins. */
contextWindowReferenceTokens?: number;
};
export type PluginHookContextWindowSource =
| "model"
| "modelsConfig"
| "agentContextTokens"
| "default";
export type PluginHookBeforeAgentReplyEvent = {
cleanedBody: string;
};
@@ -229,6 +241,12 @@ export type PluginHookModelCallBaseEvent = {
model: string;
api?: string;
transport?: string;
/** Resolved effective context-token budget after model/config/agent caps. */
contextTokenBudget?: number;
/** Source that supplied the resolved context-token budget. */
contextWindowSource?: PluginHookContextWindowSource;
/** Native/configured reference window when a lower cap wins. */
contextWindowReferenceTokens?: number;
};
export type PluginHookModelCallStartedEvent = PluginHookModelCallBaseEvent;
@@ -249,6 +267,12 @@ export type PluginHookLlmOutputEvent = {
sessionId: string;
provider: string;
model: string;
/** Resolved effective context-token budget after model/config/agent caps. */
contextTokenBudget?: number;
/** Source that supplied the resolved context-token budget. */
contextWindowSource?: PluginHookContextWindowSource;
/** Native/configured reference window when a lower cap wins. */
contextWindowReferenceTokens?: number;
/**
* Fully resolved provider/model ref used for the call.
*