mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 06:21:14 +00:00
Embedded runs targeting a CLI runtime provider fall through to the openclaw
harness and call the provider API directly with the runtime's credentials
(cli_runtime_passthrough_openclaw). Anthropic routes direct anthropic-messages
calls on subscription OAuth tokens to metered extra-usage billing — this is
long-standing behavior, not a recent change. Without extra-usage balance,
every such run (e.g. active-memory recall) fails with a billing error; with
extra-usage enabled, the run silently draws paid metered usage instead of the
plan limits the CLI runtime was configured for. Only CLI-backed execution
runs on plan limits for those credentials.
Add an opt-in RunEmbeddedAgentParams.cliBackendDispatch: "subscription-auth"
that dispatches the run through runCliAgent as a one-shot turn when the
provider is claude-cli, a CLI backend is registered, and the ordered auth
profile selection for the passthrough resolves to a subscription (oauth/token)
credential or nothing rather than an API key; resolution stays on stored
credential metadata, with no credential materialization or refresh on the
per-turn path. The dispatch translates toolsAllow into the selectable-backend
surface (native: [], allowlisted loopback MCP tools; wildcard allowlists stay
MCP-only), runs with a fresh CLI process (no live-session reuse; session-
scoped bundle-MCP retirement on run end rather than the process-wide loopback
close), bridges CLI tool result events to onAgentToolResult with native-path
semantics (normalizeToolName + isToolResultError), and drops CLI session
bindings from the result.
The selectable-backend MCP list now also bounds the loopback MCP grant
server-side: the grant carries a per-run gateway tool allowlist enforced in
scoped tool resolution, so tools outside the run's allowlist can be neither
listed nor called even under CLI bypass permission modes where
--allowedTools is advisory.
active-memory recall opts in so recall works on claude-cli subscription-only
instances and stops drawing metered extra usage where it previously could.
Scoped to claude-cli; other CLI runtimes keep the passthrough until their
direct-API contract is verified.
The dispatch also mirrors the run into the run's session transcript through the session
accessor (user turn, tool call/result records as they stream, final assistant
snapshot at run end) so transcript consumers keep parity with embedded runs:
active-memory's persistTranscripts, timeout partial-text salvage, and the
live terminal-search watcher that polls the session file mid-run.
Post-review hardening: canonical anthropic/<model> refs whose configured
agentRuntime is claude-cli resolve through the runtime policy before the
dispatch gate (they previously stayed on the failing passthrough); restricted
dispatches serve an exclusive loopback-only MCP bundle so user/plugin MCP
servers stay outside the run's tool universe; and the transcript recorder
flushes the latest assistant snapshot the moment the run aborts, so timeout
salvage sees partial text even while the killed CLI child is still settling.
Recalls routed to the claude-cli runtime default to a 45s budget (measured
CLI-dispatched runs take 14-20s, over the plain 15s default); explicit
timeoutMs config always wins.
Transcript mirror keeps bare-array tool_result content (claude stream-json
echoes MCP results without a {content} wrapper); dropping it classified every
successful recall as no_relevant_memory.
CLI dispatch resolves inside session/global lane admission so dispatched
runs obey the same lifecycle, placement, and concurrency gates as native
embedded runs.
LOC-ratchet offsets move the loopback grant-context builders to
cli-runner/mcp-grant-context.ts and dedupe the run/prep stage-summary
emitters into attempt-stage-timing.ts; unused type exports dropped and the
now-used stream-message baseline entry removed.
The recall timeout default now consumes the runner's own dispatch
eligibility through a new plugin-runtime seam
(agent.resolveCliBackendDispatchEligibility): API-key and missing-backend
routes keep the passthrough and its plain 15s default.
Eligibility honors an explicitly pinned authProfileId (the credential the
run executes on) before ordered profile selection, in both directions.
Transcript mirror composes buildAssistantMessage + buildUsageWithNoCost
directly; main trimmed the zero-usage wrapper export (ab0ccc244b) before
this branch's usage landed.
Dispatch eligibility is provider-owned: the anthropic plugin's claude-cli
backend declares CliBackendPlugin.subscriptionAuthDispatch and core reads
the registered descriptor instead of a core provider allowlist.
Dispatch fails closed on tool policy (only non-empty named allowlists are
expressible on the CLI surface; deny-all, wildcards, absent allowlists, and
disableTools/modelRun keep the passthrough), threads the pinned
authProfileId into CLI runtime resolution, and emits onExecutionStarted at
the admitted dispatch boundary.
440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
/**
|
|
* Active Memory plugin entry. Runtime behavior lives in focused sibling modules.
|
|
*/
|
|
import { resolveAgentDir, resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
|
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
import {
|
|
applyCliRuntimeRecallTimeoutDefault,
|
|
hasDeprecatedModelFallbackPolicy,
|
|
isMissingRegisteredMemoryToolsError,
|
|
normalizePluginConfig,
|
|
resetActiveMemoryConfigForTests,
|
|
setMinimumTimeoutMsForTests,
|
|
setSetupGraceTimeoutMsForTests,
|
|
} from "./config.js";
|
|
import { buildMetadata, buildPromptPrefix } from "./prompt.js";
|
|
import { buildQuery, buildSearchQuery, extractRecentTurns, getModelRef } from "./query.js";
|
|
import {
|
|
buildCacheKey,
|
|
buildCircuitBreakerKey,
|
|
getCachedResult,
|
|
getCircuitBreakerEntry,
|
|
isCircuitBreakerOpen,
|
|
resetActiveRecallStateForTests,
|
|
setCachedResult,
|
|
shouldCacheResult,
|
|
toSingleLineLogValue,
|
|
} from "./recall-state.js";
|
|
import { maybeResolveActiveRecall } from "./recall.js";
|
|
import {
|
|
ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT,
|
|
formatActiveMemoryCommandHelp,
|
|
isActiveMemoryGloballyEnabled,
|
|
isAllowedChatId,
|
|
isAllowedChatType,
|
|
isEligibleInteractiveSession,
|
|
isEnabledForAgent,
|
|
isSessionActiveMemoryDisabled,
|
|
lacksAdminToMutateActiveMemoryGlobal,
|
|
resolveCommandSessionKey,
|
|
setSessionActiveMemoryDisabled,
|
|
shouldSkipActiveMemoryForHarnessSession,
|
|
updateActiveMemoryGlobalEnabledInConfig,
|
|
} from "./session-policy.js";
|
|
import {
|
|
buildPluginStatusLine,
|
|
persistPluginStatusLines,
|
|
resolveCanonicalSessionKeyFromSessionId,
|
|
resolveStatusUpdateAgentId,
|
|
} from "./session.js";
|
|
import {
|
|
readPartialAssistantText,
|
|
resetActiveMemoryTranscriptForTests,
|
|
setTimeoutPartialDataGraceMsForTests,
|
|
} from "./transcript-result.js";
|
|
import { readActiveMemorySearchDebug } from "./transcript-watch.js";
|
|
import {
|
|
createActiveMemoryHookDeadline,
|
|
hasUsableMemoryResultInSessionRecord,
|
|
} from "./transcript.js";
|
|
import {
|
|
HOOK_TIMEOUT_RECOVERY_GRACE_MS,
|
|
MAX_SETUP_GRACE_TIMEOUT_MS,
|
|
MAX_TIMEOUT_MS,
|
|
} from "./types.js";
|
|
|
|
/** Plugin entry registering Active Memory hooks, tools, config schema, and doctor cleanup. */
|
|
export default definePluginEntry({
|
|
id: "active-memory",
|
|
name: "Active Memory",
|
|
description: "Proactively surfaces relevant memory before eligible conversational replies.",
|
|
register(api: OpenClawPluginApi) {
|
|
const readCurrentConfig = (): OpenClawConfig | undefined => {
|
|
try {
|
|
return (
|
|
(api.runtime.config?.current?.() as OpenClawConfig | undefined) ??
|
|
(api.config as OpenClawConfig | undefined)
|
|
);
|
|
} catch {
|
|
return api.config as OpenClawConfig | undefined;
|
|
}
|
|
};
|
|
let config = normalizePluginConfig(api.pluginConfig, readCurrentConfig());
|
|
const warnDeprecatedModelFallbackPolicy = (pluginConfig: unknown) => {
|
|
if (hasDeprecatedModelFallbackPolicy(pluginConfig)) {
|
|
// Wording matters here: the previous text ("set config.modelFallback
|
|
// explicitly if you want a fallback model") read naturally as runtime
|
|
// failover (model A errors → switch to model B), but `getModelRef`
|
|
// only consults `modelFallback` as the *last candidate* in the
|
|
// resolution chain after `config.model`, the current run's model,
|
|
// and the agent's configured default have all resolved to nothing.
|
|
// Surface the chain-resolution semantics directly so operators
|
|
// don't waste debug cycles assuming runtime failover (#74587).
|
|
api.logger.warn?.(
|
|
"active-memory: config.modelFallbackPolicy is deprecated and no longer changes runtime behavior. " +
|
|
"config.modelFallback is a chain-resolution last-resort (consulted only when config.model, " +
|
|
"the current run's model, and the agent's configured default all resolve to nothing) — " +
|
|
"it is NOT a runtime failover that substitutes a different model when the resolved model errors out.",
|
|
);
|
|
}
|
|
};
|
|
warnDeprecatedModelFallbackPolicy(api.pluginConfig);
|
|
const refreshLiveConfigFromRuntime = () => {
|
|
const livePluginConfig = resolveLivePluginConfigObject(
|
|
api.runtime.config?.current
|
|
? () => api.runtime.config.current() as OpenClawConfig
|
|
: undefined,
|
|
"active-memory",
|
|
api.pluginConfig as Record<string, unknown>,
|
|
);
|
|
config = normalizePluginConfig(livePluginConfig ?? { enabled: false }, readCurrentConfig());
|
|
if (livePluginConfig) {
|
|
warnDeprecatedModelFallbackPolicy(livePluginConfig);
|
|
}
|
|
};
|
|
api.registerCommand({
|
|
name: "active-memory",
|
|
description: "Enable, disable, or inspect Active Memory for this session.",
|
|
acceptsArgs: true,
|
|
exposeSenderIsOwner: true,
|
|
handler: async (ctx) => {
|
|
const tokens = ctx.args?.trim().split(/\s+/).filter(Boolean) ?? [];
|
|
const isGlobal = tokens.includes("--global");
|
|
const action = (tokens.find((token) => token !== "--global") ?? "status").toLowerCase();
|
|
if (action === "help") {
|
|
return { text: formatActiveMemoryCommandHelp() };
|
|
}
|
|
if (isGlobal) {
|
|
const currentConfig = api.runtime.config.current() as OpenClawConfig;
|
|
if (action === "status") {
|
|
return {
|
|
text: `Active Memory: ${isActiveMemoryGloballyEnabled(currentConfig) ? "on" : "off"} globally.`,
|
|
};
|
|
}
|
|
if (
|
|
lacksAdminToMutateActiveMemoryGlobal({
|
|
senderIsOwner: ctx.senderIsOwner,
|
|
gatewayClientScopes: ctx.gatewayClientScopes,
|
|
})
|
|
) {
|
|
return {
|
|
text: ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT,
|
|
};
|
|
}
|
|
if (action === "on" || action === "enable" || action === "enabled") {
|
|
await api.runtime.config.mutateConfigFile({
|
|
afterWrite: { mode: "auto" },
|
|
mutate: (draft) => {
|
|
const nextConfig = updateActiveMemoryGlobalEnabledInConfig(draft, true);
|
|
Object.assign(draft, nextConfig);
|
|
},
|
|
});
|
|
refreshLiveConfigFromRuntime();
|
|
return { text: "Active Memory: on globally." };
|
|
}
|
|
if (action === "off" || action === "disable" || action === "disabled") {
|
|
await api.runtime.config.mutateConfigFile({
|
|
afterWrite: { mode: "auto" },
|
|
mutate: (draft) => {
|
|
const nextConfig = updateActiveMemoryGlobalEnabledInConfig(draft, false);
|
|
Object.assign(draft, nextConfig);
|
|
},
|
|
});
|
|
refreshLiveConfigFromRuntime();
|
|
return { text: "Active Memory: off globally." };
|
|
}
|
|
}
|
|
const sessionKey = resolveCommandSessionKey({
|
|
api,
|
|
config,
|
|
sessionKey: ctx.sessionKey,
|
|
sessionId: ctx.sessionId,
|
|
});
|
|
if (!sessionKey) {
|
|
return {
|
|
text: "Active Memory: session toggle unavailable because this command has no session context.",
|
|
};
|
|
}
|
|
const commandAgentId = resolveStatusUpdateAgentId({ sessionKey });
|
|
if (!isEnabledForAgent(config, commandAgentId)) {
|
|
return { text: "Active Memory: off for this session." };
|
|
}
|
|
if (action === "status") {
|
|
const disabled = await isSessionActiveMemoryDisabled({ api, sessionKey });
|
|
return {
|
|
text: `Active Memory: ${disabled ? "off" : "on"} for this session.`,
|
|
};
|
|
}
|
|
if (action === "on" || action === "enable" || action === "enabled") {
|
|
await setSessionActiveMemoryDisabled({ api, sessionKey, disabled: false });
|
|
return { text: "Active Memory: on for this session." };
|
|
}
|
|
if (action === "off" || action === "disable" || action === "disabled") {
|
|
await setSessionActiveMemoryDisabled({ api, sessionKey, disabled: true });
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: resolveStatusUpdateAgentId({ sessionKey }),
|
|
sessionKey,
|
|
});
|
|
return { text: "Active Memory: off for this session." };
|
|
}
|
|
return {
|
|
text: `Unknown Active Memory action: ${action}\n\n${formatActiveMemoryCommandHelp()}`,
|
|
};
|
|
},
|
|
});
|
|
|
|
// Preflight and recall own separate deadlines. Reserve enough hook time for
|
|
// both maxima so preflight latency cannot consume recall settlement time.
|
|
const beforePromptBuildTimeoutMs =
|
|
MAX_TIMEOUT_MS + MAX_SETUP_GRACE_TIMEOUT_MS + HOOK_TIMEOUT_RECOVERY_GRACE_MS * 2;
|
|
api.on(
|
|
"before_prompt_build",
|
|
async (event, ctx) => {
|
|
refreshLiveConfigFromRuntime();
|
|
// The hook deadline, watchdog, and embedded-run budget all flow from
|
|
// this config, so the CLI-runtime default raise must happen before
|
|
// any of them are armed. Budgeting shares the runner's own dispatch
|
|
// eligibility so API-key/missing-backend passthrough runs keep the
|
|
// plain default.
|
|
const timeoutAgentId = resolveStatusUpdateAgentId(ctx);
|
|
// getModelRef returns undefined when no recall model resolves; the
|
|
// eligibility check treats a missing provider as ineligible.
|
|
const timeoutModelRef =
|
|
(timeoutAgentId
|
|
? getModelRef(api, timeoutAgentId, config, {
|
|
modelProviderId: ctx.modelProviderId,
|
|
modelId: ctx.modelId,
|
|
})
|
|
: { provider: ctx.modelProviderId, model: ctx.modelId }) ?? {};
|
|
const cliDispatchEligibility = api.runtime.agent.resolveCliBackendDispatchEligibility({
|
|
provider: timeoutModelRef.provider,
|
|
model: timeoutModelRef.model,
|
|
config: api.config,
|
|
...(timeoutAgentId
|
|
? {
|
|
agentId: timeoutAgentId,
|
|
agentDir: resolveAgentDir(api.config, timeoutAgentId),
|
|
workspaceDir: resolveAgentWorkspaceDir(api.config, timeoutAgentId),
|
|
}
|
|
: {}),
|
|
});
|
|
const invocationConfig = applyCliRuntimeRecallTimeoutDefault(
|
|
config,
|
|
cliDispatchEligibility !== undefined,
|
|
);
|
|
const liveRecallTimeoutMs =
|
|
invocationConfig.timeoutMs +
|
|
invocationConfig.setupGraceTimeoutMs +
|
|
HOOK_TIMEOUT_RECOVERY_GRACE_MS;
|
|
const deadlineController = new AbortController();
|
|
const hookDeadline = createActiveMemoryHookDeadline();
|
|
const armHookDeadline = (timeoutMs: number, phase: "preflight" | "recall") => {
|
|
hookDeadline.arm(timeoutMs, () => {
|
|
deadlineController.abort(
|
|
new Error(`active-memory ${phase} timeout after ${timeoutMs}ms`),
|
|
);
|
|
api.logger.warn?.(
|
|
`active-memory: before_prompt_build ${phase} timed out after ${String(timeoutMs)}ms; skipping memory lookup`,
|
|
);
|
|
});
|
|
};
|
|
armHookDeadline(HOOK_TIMEOUT_RECOVERY_GRACE_MS, "preflight");
|
|
const handlerPromise = (async () => {
|
|
try {
|
|
const resolvedAgentId = resolveStatusUpdateAgentId(ctx);
|
|
const resolvedSessionKey =
|
|
ctx.sessionKey?.trim() ||
|
|
(resolvedAgentId
|
|
? resolveCanonicalSessionKeyFromSessionId({
|
|
api,
|
|
agentId: resolvedAgentId,
|
|
sessionId: ctx.sessionId,
|
|
})
|
|
: undefined);
|
|
const effectiveAgentId =
|
|
resolvedAgentId || resolveStatusUpdateAgentId({ sessionKey: resolvedSessionKey });
|
|
if (
|
|
shouldSkipActiveMemoryForHarnessSession({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
})
|
|
) {
|
|
return undefined;
|
|
}
|
|
const sessionDisabled = await isSessionActiveMemoryDisabled({
|
|
api,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
deadlineController.signal.throwIfAborted();
|
|
if (sessionDisabled) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
if (!isEnabledForAgent(invocationConfig, effectiveAgentId)) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
if (
|
|
!isEligibleInteractiveSession({
|
|
...ctx,
|
|
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
|
|
})
|
|
) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
if (
|
|
!isAllowedChatType(invocationConfig, {
|
|
...ctx,
|
|
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
|
|
mainKey: api.config.session?.mainKey,
|
|
})
|
|
) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
if (
|
|
!isAllowedChatId(invocationConfig, {
|
|
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
|
|
messageProvider: ctx.messageProvider,
|
|
})
|
|
) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
const recentTurns = extractRecentTurns(event.messages);
|
|
const query = buildQuery({
|
|
latestUserMessage: event.prompt,
|
|
recentTurns,
|
|
config: invocationConfig,
|
|
});
|
|
const searchQuery = buildSearchQuery({
|
|
latestUserMessage: event.prompt,
|
|
recentTurns,
|
|
});
|
|
// Start recall with its full configured budget. The preceding
|
|
// session/config checks must not consume abort-settlement time.
|
|
armHookDeadline(liveRecallTimeoutMs, "recall");
|
|
const result = await maybeResolveActiveRecall({
|
|
api,
|
|
config: invocationConfig,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
sessionId: ctx.sessionId,
|
|
messageProvider: ctx.messageProvider,
|
|
channelId: ctx.channelId,
|
|
query,
|
|
searchQuery,
|
|
currentModelProviderId: ctx.modelProviderId,
|
|
currentModelId: ctx.modelId,
|
|
abortSignal: deadlineController.signal,
|
|
});
|
|
deadlineController.signal.throwIfAborted();
|
|
if (!result.summary) {
|
|
return undefined;
|
|
}
|
|
const promptPrefix = buildPromptPrefix(result.summary);
|
|
if (!promptPrefix) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
prependContext: promptPrefix,
|
|
};
|
|
} catch (error) {
|
|
if (deadlineController.signal.aborted) {
|
|
return undefined;
|
|
}
|
|
const message = toSingleLineLogValue(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
api.logger.warn?.(
|
|
`active-memory: before_prompt_build failed, skipping memory lookup: ${message}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
})();
|
|
try {
|
|
const result = await Promise.race([handlerPromise, hookDeadline.promise]);
|
|
return typeof result === "symbol" ? undefined : result;
|
|
} finally {
|
|
hookDeadline.stop();
|
|
}
|
|
},
|
|
{ timeoutMs: beforePromptBuildTimeoutMs },
|
|
);
|
|
},
|
|
});
|
|
|
|
const testing = {
|
|
buildSearchQuery,
|
|
buildCacheKey,
|
|
buildCircuitBreakerKey,
|
|
buildMetadata,
|
|
buildPluginStatusLine,
|
|
buildPromptPrefix,
|
|
getCachedResult,
|
|
hasUsableMemoryResultInSessionRecord,
|
|
isCircuitBreakerOpen,
|
|
isMissingRegisteredMemoryToolsError,
|
|
normalizePluginConfig,
|
|
readActiveMemorySearchDebug,
|
|
readPartialAssistantText,
|
|
shouldCacheResult,
|
|
resetActiveRecallCacheForTests() {
|
|
resetActiveRecallStateForTests();
|
|
resetActiveMemoryConfigForTests();
|
|
resetActiveMemoryTranscriptForTests();
|
|
},
|
|
setMinimumTimeoutMsForTests,
|
|
setSetupGraceTimeoutMsForTests,
|
|
setTimeoutPartialDataGraceMsForTests,
|
|
setCachedResult,
|
|
getCircuitBreakerEntry,
|
|
};
|
|
|
|
export { testing, testing as __testing };
|