Files
openclaw/src/agents/openclaw-plugin-tools.ts
sandieman2 c67dc59b02 fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread (#90943)
* fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread

Two core bugs caused composed replies to be silently dropped (no delivery,
no error) when a second message arrived in the same thread mid-run:

1. dispatch-from-config: ensureDispatchReplyOperation only kept the
   dispatch-owned operation authoritative while it had no result. Once
   runReplyAgent completed the operation to drain queued follow-ups, a
   second same-thread inbound could claim the session and the first final
   reply would try to re-acquire the lane instead of finishing delivery,
   deadlocking behind the queued work. Keep the dispatch-owned operation
   authoritative through final delivery.

2. reply-payloads-dedupe: messaging-tool reply dedupe compared only the
   channel target, not the routed thread, so a send in one thread could
   suppress a later reply in a different thread. Thread the routed thread
   id through buildReplyPayloads + follow-up delivery and only fall back to
   channel-only matching for providers without a thread-aware suppression
   matcher when neither side carries thread evidence.

Adds regression tests; existing Telegram topic-suppression behavior is
preserved by gating the thread guard to providers lacking a plugin matcher.

* fix(reply): preserve threaded message delivery evidence

* fix(reply): dedupe final payloads by delivery route

* fix(slack): preserve native send thread evidence

* fix(reply): preserve explicit reply thread evidence

* fix(reply): align explicit reply route dedupe

* fix(reply): preserve delivery lane through final dispatch

* fix(mattermost): preserve threaded tool send routes

* chore(plugin-sdk): refresh API baseline

* fix(reply): align final delivery route dedupe

* fix(reply): gate followups on final delivery

* fix(reply): keep send receipts private

* fix(reply): infer implicit message provider

* fix(reply): align routed threading policy

* fix(reply): preserve queued delivery context

* fix(reply): hydrate queued system event routes

* fix(reply): hydrate queued execution routes

* fix(reply): scope final delivery barriers

* fix(slack): preserve DM target aliases

* fix(reply): mirror resolved source thread routes

* fix(mattermost): retain delayed delivery barrier

* fix(codex): separate message routing from tool policy

* fix(reply): consume normalized Slack DM targets once

* fix(slack): remove stale target alias

* style(reply): satisfy changed lint gates

* fix(mattermost): preserve explicit reply targets

* test: align Slack reply branch checks

* fix(reply): persist overflow summaries to admitted session

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-14 09:11:05 -07:00

139 lines
4.9 KiB
TypeScript

/**
* OpenClaw plugin tool resolver.
*
* This module builds runtime plugin tools from config/options, delivery context,
* auth profiles, and the current runtime config snapshot.
*/
import { selectApplicableRuntimeConfig } from "../config/config.js";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
} from "../config/runtime-snapshot.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolvePluginTools } from "../plugins/tools.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { resolveApiKeyForProfile, resolveAuthProfileOrder } from "./auth-profiles.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import {
resolveOpenClawPluginToolInputs,
type OpenClawPluginToolOptions,
} from "./openclaw-tools.plugin-context.js";
import { applyPluginToolDeliveryDefaults } from "./plugin-tool-delivery-defaults.js";
import type { AnyAgentTool } from "./tools/common.js";
type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & {
pluginToolAllowlist?: string[];
pluginToolDenylist?: string[];
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
sandboxRoot?: string;
modelHasVision?: boolean;
modelProvider?: string;
modelId?: string;
allowMediaInvokeCommands?: boolean;
requesterAgentIdOverride?: string;
requireExplicitMessageTarget?: boolean;
disableMessageTool?: boolean;
disablePluginTools?: boolean;
authProfileStore?: AuthProfileStore;
};
function resolveApplicablePluginRuntimeConfig(
inputConfig?: OpenClawConfig,
): OpenClawConfig | undefined {
const runtimeConfig = getRuntimeConfigSnapshot() ?? undefined;
if (!runtimeConfig) {
return inputConfig;
}
if (!inputConfig || inputConfig === runtimeConfig) {
return runtimeConfig;
}
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot() ?? undefined;
if (!runtimeSourceConfig) {
return inputConfig;
}
return selectApplicableRuntimeConfig({
inputConfig,
runtimeConfig,
runtimeSourceConfig,
});
}
/** Resolves plugin tools for an agent run and applies delivery-context defaults. */
export function resolveOpenClawPluginToolsForOptions(params: {
options?: ResolveOpenClawPluginToolsOptions;
resolvedConfig?: OpenClawConfig;
existingToolNames?: Set<string>;
}): AnyAgentTool[] {
if (params.options?.disablePluginTools) {
return [];
}
const deliveryContext = normalizeDeliveryContext({
channel: params.options?.agentChannel,
to: params.options?.agentTo,
accountId: params.options?.agentAccountId,
threadId: params.options?.agentThreadId,
});
const resolveCurrentRuntimeConfig = () => {
// Re-resolve on demand so auth/profile lookups see the active runtime config
// while tests can still inject a fixed resolvedConfig.
return resolveApplicablePluginRuntimeConfig(params.resolvedConfig ?? params.options?.config);
};
const authProfileStore = params.options?.authProfileStore;
const resolveAuthProfileIdsForProvider = authProfileStore
? (providerId: string): string[] =>
resolveAuthProfileOrder({
cfg: resolveCurrentRuntimeConfig(),
store: authProfileStore,
provider: providerId,
})
: undefined;
const hasAuthForProvider = authProfileStore
? (providerId: string) => (resolveAuthProfileIdsForProvider?.(providerId) ?? []).length > 0
: undefined;
const resolveApiKeyForProvider = authProfileStore
? async (providerId: string): Promise<string | undefined> => {
for (const profileId of resolveAuthProfileIdsForProvider?.(providerId) ?? []) {
const resolved = await resolveApiKeyForProfile({
cfg: resolveCurrentRuntimeConfig(),
store: authProfileStore,
profileId,
agentDir: params.options?.agentDir,
});
if (resolved?.apiKey) {
return resolved.apiKey;
}
}
return undefined;
}
: undefined;
const pluginToolInputs = resolveOpenClawPluginToolInputs({
options: params.options,
resolvedConfig: params.resolvedConfig,
runtimeConfig: resolveCurrentRuntimeConfig(),
getRuntimeConfig: resolveCurrentRuntimeConfig,
});
const pluginTools = resolvePluginTools({
...pluginToolInputs,
context: {
...pluginToolInputs.context,
...(hasAuthForProvider ? { hasAuthForProvider } : {}),
...(resolveApiKeyForProvider ? { resolveApiKeyForProvider } : {}),
},
existingToolNames: params.existingToolNames ?? new Set<string>(),
toolAllowlist: params.options?.pluginToolAllowlist,
toolDenylist: params.options?.pluginToolDenylist,
allowGatewaySubagentBinding: params.options?.allowGatewaySubagentBinding,
...(hasAuthForProvider ? { hasAuthForProvider } : {}),
});
return applyPluginToolDeliveryDefaults({
tools: pluginTools,
deliveryContext,
});
}