feature(context): extend plugin system to support custom context management (#22201)

* feat(context-engine): add ContextEngine interface and registry

Introduce the pluggable ContextEngine abstraction that allows external
plugins to register custom context management strategies.

- ContextEngine interface with lifecycle methods: bootstrap, ingest,
  ingestBatch, afterTurn, assemble, compact, prepareSubagentSpawn,
  onSubagentEnded, dispose
- Module-level singleton registry with registerContextEngine() and
  resolveContextEngine() (config-driven slot selection)
- LegacyContextEngine: pass-through implementation wrapping existing
  compaction behavior for 100% backward compatibility
- ensureContextEnginesInitialized() guard for safe one-time registration
- 19 tests covering contract, registry, resolution, and legacy parity

* feat(plugins): add context-engine slot and registerContextEngine API

Wire the ContextEngine abstraction into the plugin system so external
plugins can register context engines via the standard plugin API.

- Add 'context-engine' to PluginKind union type
- Add 'contextEngine' slot to PluginSlotsConfig (default: 'legacy')
- Wire registerContextEngine() through OpenClawPluginApi
- Export ContextEngine types from plugin-sdk for external consumers
- Restore proper slot-based resolution in registry

* feat(context-engine): wire ContextEngine into agent run lifecycle

Integrate the ContextEngine abstraction into the core agent run path:

- Resolve context engine once per run (reused across retries)
- Bootstrap: hydrate canonical store from session file on first run
- Assemble: route context assembly through pluggable engine
- Auto-compaction guard: disable built-in auto-compaction when
  the engine declares ownsCompaction (prevents double-compaction)
- AfterTurn: post-turn lifecycle hook for ingest + background
  compaction decisions
- Overflow compaction: route through contextEngine.compact()
- Dispose: clean up engine resources in finally block
- Notify context engine on subagent lifecycle events

Legacy engine: all lifecycle methods are pass-through/no-op, preserving
100% backward compatibility for users without a context engine plugin.

* feat(plugins): add scoped subagent methods and gateway request scope

Expose runtime.subagent.{run, waitForRun, getSession, deleteSession}
so external plugins can spawn sub-agent sessions without raw gateway
dispatch access.

Uses AsyncLocalStorage request-scope bridge to dispatch internally via
handleGatewayRequest with a synthetic operator client. Methods are only
available during gateway request handling.

- Symbol.for-backed global singleton for cross-module-reload safety
- Fallback gateway context for non-WS dispatch paths (Telegram/WhatsApp)
- Set gateway request scope for all handlers, not just plugin handlers
- 3 staleness tests for fallback context hardening

* feat(context-engine): route /compact and sessions.get through context engine

Wire the /compact command and sessions.get handler through the pluggable
ContextEngine interface.

- Thread tokenBudget and force parameters to context engine compact
- Route /compact through contextEngine.compact() when registered
- Wire sessions.get as runtime alias for plugin subagent dispatch
- Add .pebbles/ to .gitignore

* style: format with oxfmt 0.33.0

Fix duplicate import (ControlUiRootState in server.impl.ts) and
import ordering across all changed files.

* fix: update extension test mocks for context-engine types

Add missing subagent property to bluebubbles PluginRuntime mock.
Add missing registerContextEngine to lobster OpenClawPluginApi mock.

* fix(subagents): keep deferred delete cleanup retryable

* style: format run attempt for CI

* fix(rebase): remove duplicate embedded-run imports

* test: add missing gateway context mock export

* fix: pass resolved auth profile into afterTurn compaction

Ensure the embedded runner forwards resolved auth profile context into
legacy context-engine compaction params on the normal afterTurn path,
matching overflow compaction behavior. This allows downstream LCM
summarization to use the intended provider auth/profile consistently.

Also fix strict TS typing in external-link token dedupe and align an
attempt unit test reasoningLevel value with the current ReasoningLevel
enum.

Regeneration-Prompt: |
  We were debugging context-engine compaction where downstream summary
  calls were missing the right auth/profile context in normal afterTurn
  flow, while overflow compaction already propagated it. Preserve current
  behavior and keep changes additive: thread the resolved authProfileId
  through run -> attempt -> legacy compaction param builder without
  broad refactors.

  Add tests that prove the auth profile is included in afterTurn legacy
  params and that overflow compaction still passes it through run
  attempts. Keep existing APIs stable, and only adjust small type issues
  needed for strict compilation.

* fix: remove duplicate imports from rebase

* feat: add context-engine system prompt additions

* fix(rebase): dedupe attempt import declarations

* test: fix fetch mock typing in ollama autodiscovery

* fix(test): add registerContextEngine to diffs extension mock APIs

* test(windows): use path.delimiter in ios-team-id fixture PATH

* test(cron): add model formatting and precedence edge case tests

Covers:
- Provider/model string splitting (whitespace, nested paths, empty segments)
- Provider normalization (casing, aliases like bedrock→amazon-bedrock)
- Anthropic model alias normalization (opus-4.5→claude-opus-4-5)
- Precedence: job payload > session override > config default
- Sequential runs with different providers (CI flake regression pattern)
- forceNew session preserving stored model overrides
- Whitespace/empty model string edge cases
- Config model as string vs object format

* test(cron): fix model formatting test config types

* test(phone-control): add registerContextEngine to mock API

* fix: re-export ChannelKind from config-reload-plan

* fix: add subagent mock to plugin-runtime-mock test util

* docs: add changelog fragment for context engine PR #22201
This commit is contained in:
Josh Lehman
2026-03-06 05:31:59 -08:00
committed by GitHub
parent fa6c0e1b40
commit fee91fefce
44 changed files with 2308 additions and 103 deletions

View File

@@ -11,6 +11,10 @@ import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
ensureContextEnginesInitialized,
resolveContextEngine,
} from "../../context-engine/index.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { generateSecureToken } from "../../infra/secure-random.js";
@@ -29,8 +33,9 @@ import { resolveSessionAgentIds } from "../agent-scope.js";
import type { ExecElevatedDefaults } from "../bash-tools.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js";
import { resolveContextWindowInfo } from "../context-window-guard.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { resolveOpenClawDocsPath } from "../docs-path.js";
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
import { ensureOpenClawModelsJson } from "../models-config.js";
@@ -115,6 +120,8 @@ export type CompactEmbeddedPiSessionParams = {
reasoningLevel?: ReasoningLevel;
bashElevated?: ExecElevatedDefaults;
customInstructions?: string;
tokenBudget?: number;
force?: boolean;
trigger?: "overflow" | "manual";
diagId?: string;
attempt?: number;
@@ -846,6 +853,49 @@ export async function compactEmbeddedPiSession(
const enqueueGlobal =
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => compactEmbeddedPiSessionDirect(params)),
enqueueGlobal(async () => {
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);
try {
// Resolve token budget from model context window so the context engine
// knows the compaction target. The runner's afterTurn path passes this
// automatically, but the /compact command path needs to compute it here.
const ceProvider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const ceModelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const { model: ceModel } = resolveModel(ceProvider, ceModelId, agentDir, params.config);
const ceCtxInfo = resolveContextWindowInfo({
cfg: params.config,
provider: ceProvider,
modelId: ceModelId,
modelContextWindow: ceModel?.contextWindow,
defaultTokens: DEFAULT_CONTEXT_TOKENS,
});
const result = await contextEngine.compact({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
tokenBudget: ceCtxInfo.tokens,
customInstructions: params.customInstructions,
force: params.trigger === "manual",
legacyParams: params as Record<string, unknown>,
});
return {
ok: result.ok,
compacted: result.compacted,
reason: result.reason,
result: result.result
? {
summary: result.result.summary ?? "",
firstKeptEntryId: result.result.firstKeptEntryId ?? "",
tokensBefore: result.result.tokensBefore,
tokensAfter: result.result.tokensAfter,
details: result.result.details,
}
: undefined,
};
} finally {
await contextEngine.dispose?.();
}
}),
);
}

View File

@@ -54,6 +54,22 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
);
});
it("passes resolved auth profile into run attempts for context-engine afterTurn propagation", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
await runEmbeddedPiAgent({
...overflowBaseRunParams,
runId: "run-auth-profile-passthrough",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId: "test-profile",
authProfileIdSource: "auto",
}),
);
});
it("passes trigger=overflow when retrying compaction after context overflow", async () => {
mockOverflowRetrySuccess({
runEmbeddedAttempt: mockedRunEmbeddedAttempt,

View File

@@ -1,6 +1,10 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import {
ensureContextEnginesInitialized,
resolveContextEngine,
} from "../../context-engine/index.js";
import { generateSecureToken } from "../../infra/secure-random.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js";
@@ -50,7 +54,6 @@ import {
} from "../pi-embedded-helpers.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
import { compactEmbeddedPiSessionDirect } from "./compact.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { resolveModel } from "./model.js";
@@ -737,6 +740,10 @@ export async function runEmbeddedPiAgent(
agentDir,
});
};
// Resolve the context engine once and reuse across retries to avoid
// repeated initialization/connection overhead per attempt.
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);
try {
let authRetryPending = false;
// Hoisted so the retry-limit error path can use the most recent API total.
@@ -806,6 +813,8 @@ export async function runEmbeddedPiAgent(
workspaceDir: resolvedWorkspace,
agentDir,
config: params.config,
contextEngine,
contextTokenBudget: ctxInfo.tokens,
skillsSnapshot: params.skillsSnapshot,
prompt,
images: params.images,
@@ -813,6 +822,8 @@ export async function runEmbeddedPiAgent(
provider,
modelId,
model,
authProfileId: lastProfileId,
authProfileIdSource: lockedProfileId ? "user" : "auto",
authStorage,
modelRegistry,
agentId: workspaceResolution.agentId,
@@ -955,31 +966,36 @@ export async function runEmbeddedPiAgent(
log.warn(
`context overflow detected (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); attempting auto-compaction for ${provider}/${modelId}`,
);
const compactResult = await compactEmbeddedPiSessionDirect({
const compactResult = await contextEngine.compact({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
authProfileId: lastProfileId,
sessionFile: params.sessionFile,
workspaceDir: resolvedWorkspace,
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
provider,
model: modelId,
runId: params.runId,
thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
trigger: "overflow",
diagId: overflowDiagId,
attempt: overflowCompactionAttempts,
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
tokenBudget: ctxInfo.tokens,
force: true,
compactionTarget: "budget",
legacyParams: {
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
authProfileId: lastProfileId,
workspaceDir: resolvedWorkspace,
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
provider,
model: modelId,
runId: params.runId,
thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
trigger: "overflow",
diagId: overflowDiagId,
attempt: overflowCompactionAttempts,
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
},
});
if (compactResult.compacted) {
autoCompactionCount += 1;
@@ -1412,6 +1428,7 @@ export async function runEmbeddedPiAgent(
};
}
} finally {
await contextEngine.dispose?.();
stopCopilotRefreshTimer();
process.chdir(prevCwd);
}

View File

@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import {
buildAfterTurnLegacyCompactionParams,
composeSystemPromptWithHookContext,
isOllamaCompatProvider,
prependSystemPromptAddition,
resolveAttemptFsWorkspaceOnly,
resolveOllamaBaseUrlForRun,
resolveOllamaCompatNumCtxEnabled,
@@ -180,7 +182,6 @@ describe("resolveAttemptFsWorkspaceOnly", () => {
).toBe(false);
});
});
describe("wrapStreamFnTrimToolCallNames", () => {
function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): {
result: () => Promise<unknown>;
@@ -548,3 +549,54 @@ describe("decodeHtmlEntitiesInObject", () => {
expect(decodeHtmlEntitiesInObject("&#x27;world&#x27;")).toBe("'world'");
});
});
describe("prependSystemPromptAddition", () => {
it("prepends context-engine addition to the system prompt", () => {
const result = prependSystemPromptAddition({
systemPrompt: "base system",
systemPromptAddition: "extra behavior",
});
expect(result).toBe("extra behavior\n\nbase system");
});
it("returns the original system prompt when no addition is provided", () => {
const result = prependSystemPromptAddition({
systemPrompt: "base system",
});
expect(result).toBe("base system");
});
});
describe("buildAfterTurnLegacyCompactionParams", () => {
it("includes resolved auth profile fields for context-engine afterTurn compaction", () => {
const legacy = buildAfterTurnLegacyCompactionParams({
attempt: {
sessionKey: "agent:main:session:abc",
messageChannel: "slack",
messageProvider: "slack",
agentAccountId: "acct-1",
authProfileId: "openai:p1",
config: { plugins: { slots: { contextEngine: "lossless-claw" } } } as OpenClawConfig,
skillsSnapshot: undefined,
senderIsOwner: true,
provider: "openai-codex",
modelId: "gpt-5.3-codex",
thinkLevel: "off",
reasoningLevel: "on",
extraSystemPrompt: "extra",
ownerNumbers: ["+15555550123"],
},
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
});
expect(legacy).toMatchObject({
authProfileId: "openai:p1",
provider: "openai-codex",
model: "gpt-5.3-codex",
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
});
});
});

View File

@@ -63,6 +63,7 @@ import {
} from "../../pi-embedded-helpers.js";
import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js";
import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settings.js";
import { applyPiAutoCompactionGuard } from "../../pi-settings.js";
import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js";
import { resolveSandboxContext } from "../../sandbox.js";
@@ -90,6 +91,7 @@ import { resolveTranscriptPolicy } from "../../transcript-policy.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
import { isRunnerAbortError } from "../abort.js";
import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js";
import type { CompactEmbeddedPiSessionParams } from "../compact.js";
import { buildEmbeddedExtensionFactories } from "../extensions.js";
import { applyExtraParamsToAgent } from "../extra-params.js";
import {
@@ -617,6 +619,60 @@ export function resolveAttemptFsWorkspaceOnly(params: {
});
}
export function prependSystemPromptAddition(params: {
systemPrompt: string;
systemPromptAddition?: string;
}): string {
if (!params.systemPromptAddition) {
return params.systemPrompt;
}
return `${params.systemPromptAddition}\n\n${params.systemPrompt}`;
}
/** Build legacy compaction params passed into context-engine afterTurn hooks. */
export function buildAfterTurnLegacyCompactionParams(params: {
attempt: Pick<
EmbeddedRunAttemptParams,
| "sessionKey"
| "messageChannel"
| "messageProvider"
| "agentAccountId"
| "config"
| "skillsSnapshot"
| "senderIsOwner"
| "provider"
| "modelId"
| "thinkLevel"
| "reasoningLevel"
| "bashElevated"
| "extraSystemPrompt"
| "ownerNumbers"
| "authProfileId"
>;
workspaceDir: string;
agentDir: string;
}): Partial<CompactEmbeddedPiSessionParams> {
return {
sessionKey: params.attempt.sessionKey,
messageChannel: params.attempt.messageChannel,
messageProvider: params.attempt.messageProvider,
agentAccountId: params.attempt.agentAccountId,
authProfileId: params.attempt.authProfileId,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
config: params.attempt.config,
skillsSnapshot: params.attempt.skillsSnapshot,
senderIsOwner: params.attempt.senderIsOwner,
provider: params.attempt.provider,
model: params.attempt.modelId,
thinkLevel: params.attempt.thinkLevel,
reasoningLevel: params.attempt.reasoningLevel,
bashElevated: params.attempt.bashElevated,
extraSystemPrompt: params.attempt.extraSystemPrompt,
ownerNumbers: params.attempt.ownerNumbers,
};
}
function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } {
const content = (msg as { content?: unknown }).content;
if (typeof content === "string") {
@@ -1025,6 +1081,17 @@ export async function runEmbeddedAttempt(
});
trackSessionManagerAccess(params.sessionFile);
if (hadSessionFile && params.contextEngine?.bootstrap) {
try {
await params.contextEngine.bootstrap({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
});
} catch (bootstrapErr) {
log.warn(`context engine bootstrap failed: ${String(bootstrapErr)}`);
}
}
await prepareSessionManagerForRun({
sessionManager,
sessionFile: params.sessionFile,
@@ -1038,6 +1105,10 @@ export async function runEmbeddedAttempt(
agentDir,
cfg: params.config,
});
applyPiAutoCompactionGuard({
settingsManager,
contextEngineInfo: params.contextEngine?.info,
});
// Sets compaction/pruning runtime state and returns extension factories
// that must be passed to the resource loader for the safeguard to be active.
@@ -1336,6 +1407,33 @@ export async function runEmbeddedAttempt(
if (limited.length > 0) {
activeSession.agent.replaceMessages(limited);
}
if (params.contextEngine) {
try {
const assembled = await params.contextEngine.assemble({
sessionId: params.sessionId,
messages: activeSession.messages,
tokenBudget: params.contextTokenBudget,
});
if (assembled.messages !== activeSession.messages) {
activeSession.agent.replaceMessages(assembled.messages);
}
if (assembled.systemPromptAddition) {
systemPromptText = prependSystemPromptAddition({
systemPrompt: systemPromptText,
systemPromptAddition: assembled.systemPromptAddition,
});
applySystemPromptOverrideToSession(activeSession, systemPromptText);
log.debug(
`context engine: prepended system prompt addition (${assembled.systemPromptAddition.length} chars)`,
);
}
} catch (assembleErr) {
log.warn(
`context engine assemble failed, using pipeline messages: ${String(assembleErr)}`,
);
}
}
} catch (err) {
await flushPendingToolResultsAfterIdle({
agent: activeSession?.agent,
@@ -1515,6 +1613,7 @@ export async function runEmbeddedAttempt(
let promptError: unknown = null;
let promptErrorSource: "prompt" | "compaction" | null = null;
const prePromptMessageCount = activeSession.messages.length;
try {
const promptStartedAt = Date.now();
@@ -1772,6 +1871,56 @@ export async function runEmbeddedAttempt(
}
}
// Let the active context engine run its post-turn lifecycle.
if (params.contextEngine) {
const afterTurnLegacyCompactionParams = buildAfterTurnLegacyCompactionParams({
attempt: params,
workspaceDir: effectiveWorkspace,
agentDir,
});
if (typeof params.contextEngine.afterTurn === "function") {
try {
await params.contextEngine.afterTurn({
sessionId: sessionIdUsed,
sessionFile: params.sessionFile,
messages: messagesSnapshot,
prePromptMessageCount,
tokenBudget: params.contextTokenBudget,
legacyCompactionParams: afterTurnLegacyCompactionParams,
});
} catch (afterTurnErr) {
log.warn(`context engine afterTurn failed: ${String(afterTurnErr)}`);
}
} else {
// Fallback: ingest new messages individually
const newMessages = messagesSnapshot.slice(prePromptMessageCount);
if (newMessages.length > 0) {
if (typeof params.contextEngine.ingestBatch === "function") {
try {
await params.contextEngine.ingestBatch({
sessionId: sessionIdUsed,
messages: newMessages,
});
} catch (ingestErr) {
log.warn(`context engine ingest failed: ${String(ingestErr)}`);
}
} else {
for (const msg of newMessages) {
try {
await params.contextEngine.ingest({
sessionId: sessionIdUsed,
message: msg,
});
} catch (ingestErr) {
log.warn(`context engine ingest failed: ${String(ingestErr)}`);
}
}
}
}
}
}
cacheTrace?.recordStage("session:after", {
messages: messagesSnapshot,
note: timedOutDuringCompaction

View File

@@ -3,6 +3,7 @@ import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
import type { ThinkLevel } from "../../../auto-reply/thinking.js";
import type { SessionSystemPromptReport } from "../../../config/sessions/types.js";
import type { ContextEngine } from "../../../context-engine/types.js";
import type { PluginHookBeforeAgentStartResult } from "../../../plugins/types.js";
import type { MessagingToolSend } from "../../pi-embedded-messaging.js";
import type { NormalizedUsage } from "../../usage.js";
@@ -14,6 +15,14 @@ type EmbeddedRunAttemptBase = Omit<
>;
export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & {
/** Pluggable context engine for ingest/assemble/compact lifecycle. */
contextEngine?: ContextEngine;
/** Resolved model context window in tokens for assemble/compact budgeting. */
contextTokenBudget?: number;
/** Auth profile resolved for this attempt's provider/model call. */
authProfileId?: string;
/** Source for the resolved auth profile (user-locked or automatic). */
authProfileIdSource?: "auto" | "user";
provider: string;
modelId: string;
model: Model<Api>;

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ContextEngineInfo } from "../context-engine/types.js";
export const DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR = 20_000;
@@ -11,6 +12,7 @@ type PiSettingsManagerLike = {
keepRecentTokens?: number;
};
}) => void;
setCompactionEnabled?: (enabled: boolean) => void;
};
export function ensurePiCompactionReserveTokens(params: {
@@ -95,3 +97,26 @@ export function applyPiCompactionSettingsFromConfig(params: {
},
};
}
/** Decide whether Pi's internal auto-compaction should be disabled for this run. */
export function shouldDisablePiAutoCompaction(params: {
contextEngineInfo?: ContextEngineInfo;
}): boolean {
return params.contextEngineInfo?.ownsCompaction === true;
}
/** Disable Pi auto-compaction via settings when a context engine owns compaction. */
export function applyPiAutoCompactionGuard(params: {
settingsManager: PiSettingsManagerLike;
contextEngineInfo?: ContextEngineInfo;
}): { supported: boolean; disabled: boolean } {
const disable = shouldDisablePiAutoCompaction({
contextEngineInfo: params.contextEngineInfo,
});
const hasMethod = typeof params.settingsManager.setCompactionEnabled === "function";
if (!disable || !hasMethod) {
return { supported: hasMethod, disabled: false };
}
params.settingsManager.setCompactionEnabled!(false);
return { supported: true, disabled: true };
}

View File

@@ -8,8 +8,12 @@ import {
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { ensureContextEnginesInitialized } from "../context-engine/init.js";
import { resolveContextEngine } from "../context-engine/registry.js";
import type { SubagentEndReason } from "../context-engine/types.js";
import { callGateway } from "../gateway/call.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { defaultRuntime } from "../runtime.js";
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
@@ -54,6 +58,7 @@ import type { SubagentRunRecord } from "./subagent-registry.types.js";
import { resolveAgentTimeoutMs } from "./timeout.js";
export type { SubagentRunRecord } from "./subagent-registry.types.js";
const log = createSubsystemLogger("agents/subagent-registry");
const subagentRuns = new Map<string, SubagentRunRecord>();
let sweeper: NodeJS.Timeout | null = null;
@@ -305,6 +310,22 @@ function schedulePendingLifecycleError(params: { runId: string; endedAt: number;
});
}
async function notifyContextEngineSubagentEnded(params: {
childSessionKey: string;
reason: SubagentEndReason;
}) {
try {
ensureContextEnginesInitialized();
const engine = await resolveContextEngine(loadConfig());
if (!engine.onSubagentEnded) {
return;
}
await engine.onSubagentEnded(params);
} catch (err) {
log.warn("context-engine onSubagentEnded failed (best-effort)", { err });
}
}
function suppressAnnounceForSteerRestart(entry?: SubagentRunRecord) {
return entry?.suppressAnnounceReason === "steer-restart";
}
@@ -690,6 +711,10 @@ async function sweepSubagentRuns() {
continue;
}
clearPendingLifecycleError(runId);
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,
reason: "swept",
});
subagentRuns.delete(runId);
mutated = true;
// Archive/purge is terminal for the run record; remove any retained attachments too.
@@ -894,9 +919,8 @@ async function finalizeSubagentCleanup(
return;
}
// Allow retry on the next wake if announce was deferred or failed.
// Applies to both keep/delete cleanup modes so delete-runs are only removed
// after a successful announce (or terminal give-up).
// Keep both cleanup modes retryable after deferred/failed announce.
// Delete-mode is finalized only after announce succeeds or give-up triggers.
entry.cleanupHandled = false;
// Clear the in-flight resume marker so the scheduled retry can run again.
resumedRuns.delete(runId);
@@ -936,11 +960,19 @@ function completeCleanupBookkeeping(params: {
}) {
if (params.cleanup === "delete") {
clearPendingLifecycleError(params.runId);
void notifyContextEngineSubagentEnded({
childSessionKey: params.entry.childSessionKey,
reason: "deleted",
});
subagentRuns.delete(params.runId);
persistSubagentRuns();
retryDeferredCompletedAnnounces(params.runId);
return;
}
void notifyContextEngineSubagentEnded({
childSessionKey: params.entry.childSessionKey,
reason: "completed",
});
params.entry.cleanupCompletedAt = params.completedAt;
persistSubagentRuns();
retryDeferredCompletedAnnounces(params.runId);
@@ -1248,6 +1280,13 @@ export function addSubagentRunForTests(entry: SubagentRunRecord) {
export function releaseSubagentRun(runId: string) {
clearPendingLifecycleError(runId);
const entry = subagentRuns.get(runId);
if (entry) {
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,
reason: "released",
});
}
const didDelete = subagentRuns.delete(runId);
if (didDelete) {
persistSubagentRuns();